Snapshot: alpha-2026-9
2650
frontend/package-lock.json
generated
|
|
@ -16,7 +16,10 @@
|
|||
"vue": "^3.2.47",
|
||||
"vue-multiselect": "^2.1.7",
|
||||
"vue-router": "^4.1.6",
|
||||
"vuex": "^4.1.0"
|
||||
"vuex": "^4.1.0",
|
||||
"extras": "file:./extras",
|
||||
"jquery": "^3.6.0",
|
||||
"popper.js": "^1.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
|
|
|
|||
BIN
frontend/public/assets/img/avatars/avatar-2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/public/assets/img/avatars/avatar-3.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/public/assets/img/avatars/avatar-4.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
frontend/public/assets/img/avatars/avatar-5.png
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
frontend/public/assets/img/avatars/avatar-6.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/public/assets/img/avatars/avatar.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/assets/img/photos/unsplash-1.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/public/assets/img/photos/unsplash-2.jpg
Normal file
|
After Width: | Height: | Size: 888 KiB |
BIN
frontend/public/assets/img/photos/unsplash-3.jpg
Normal file
|
After Width: | Height: | Size: 970 KiB |
16
frontend/public/vendor/freetype.js
vendored
Normal file
BIN
frontend/public/vendor/freetype.wasm
vendored
Normal file
2
frontend/public/vendor/libweblabel.js
vendored
Normal file
BIN
frontend/public/vendor/libweblabel.wasm
vendored
Normal file
20696
frontend/src/assets/css/toolshed.css
Normal file
BIN
frontend/src/assets/fonts/fa-brands-400.eot
Normal file
3570
frontend/src/assets/fonts/fa-brands-400.svg
Normal file
|
After Width: | Height: | Size: 699 KiB |
BIN
frontend/src/assets/fonts/fa-brands-400.ttf
Normal file
BIN
frontend/src/assets/fonts/fa-brands-400.woff
Normal file
BIN
frontend/src/assets/fonts/fa-brands-400.woff2
Normal file
BIN
frontend/src/assets/fonts/fa-regular-400.eot
Normal file
801
frontend/src/assets/fonts/fa-regular-400.svg
Normal file
|
|
@ -0,0 +1,801 @@
|
|||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
|
||||
<metadata>
|
||||
Created by FontForge 20200314 at Mon Oct 5 09:50:45 2020
|
||||
By Robert Madole
|
||||
Copyright (c) Font Awesome
|
||||
</metadata>
|
||||
<!-- Font Awesome Free 5.15.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) --><defs>
|
||||
<font id="FontAwesome5Free-Regular" horiz-adv-x="512" >
|
||||
<font-face
|
||||
font-family="Font Awesome 5 Free Regular"
|
||||
font-weight="400"
|
||||
font-stretch="normal"
|
||||
units-per-em="512"
|
||||
panose-1="2 0 5 3 0 0 0 0 0 0"
|
||||
ascent="448"
|
||||
descent="-64"
|
||||
bbox="-0.0663408 -64.0662 640.004 448.1"
|
||||
underline-thickness="25"
|
||||
underline-position="-50"
|
||||
unicode-range="U+0020-F5C8"
|
||||
/>
|
||||
<missing-glyph />
|
||||
<glyph glyph-name="heart" unicode=""
|
||||
d="M458.4 383.7c75.2998 -63.4004 64.0996 -166.601 10.5996 -221.3l-175.4 -178.7c-10 -10.2002 -23.2998 -15.7998 -37.5996 -15.7998c-14.2002 0 -27.5996 5.69922 -37.5996 15.8994l-175.4 178.7c-53.5996 54.7002 -64.5996 157.9 10.5996 221.2
|
||||
c57.8008 48.7002 147.101 41.2998 202.4 -15c55.2998 56.2998 144.6 63.5996 202.4 15zM434.8 196.2c36.2002 36.8994 43.7998 107.7 -7.2998 150.8c-38.7002 32.5996 -98.7002 27.9004 -136.5 -10.5996l-35 -35.7002l-35 35.7002
|
||||
c-37.5996 38.2998 -97.5996 43.1992 -136.5 10.5c-51.2002 -43.1006 -43.7998 -113.5 -7.2998 -150.7l175.399 -178.7c2.40039 -2.40039 4.40039 -2.40039 6.80078 0z" />
|
||||
<glyph glyph-name="star" unicode="" horiz-adv-x="576"
|
||||
d="M528.1 276.5c26.2002 -3.7998 36.7002 -36.0996 17.7002 -54.5996l-105.7 -103l25 -145.5c4.5 -26.3008 -23.1992 -45.9004 -46.3994 -33.7002l-130.7 68.7002l-130.7 -68.7002c-23.2002 -12.2998 -50.8994 7.39941 -46.3994 33.7002l25 145.5l-105.7 103
|
||||
c-19 18.5 -8.5 50.7998 17.7002 54.5996l146.1 21.2998l65.2998 132.4c11.7998 23.8994 45.7002 23.5996 57.4004 0l65.2998 -132.4zM388.6 135.7l100.601 98l-139 20.2002l-62.2002 126l-62.2002 -126l-139 -20.2002l100.601 -98l-23.7002 -138.4l124.3 65.2998
|
||||
l124.3 -65.2998z" />
|
||||
<glyph glyph-name="user" unicode="" horiz-adv-x="448"
|
||||
d="M313.6 144c74.2002 0 134.4 -60.2002 134.4 -134.4v-25.5996c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v25.5996c0 74.2002 60.2002 134.4 134.4 134.4c28.7998 0 42.5 -16 89.5996 -16s60.9004 16 89.5996 16zM400 -16v25.5996
|
||||
c0 47.6006 -38.7998 86.4004 -86.4004 86.4004c-14.6992 0 -37.8994 -16 -89.5996 -16c-51.2998 0 -75 16 -89.5996 16c-47.6006 0 -86.4004 -38.7998 -86.4004 -86.4004v-25.5996h352zM224 160c-79.5 0 -144 64.5 -144 144s64.5 144 144 144s144 -64.5 144 -144
|
||||
s-64.5 -144 -144 -144zM224 400c-52.9004 0 -96 -43.0996 -96 -96s43.0996 -96 96 -96s96 43.0996 96 96s-43.0996 96 -96 96z" />
|
||||
<glyph glyph-name="clock" unicode=""
|
||||
d="M256 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM256 -8c110.5 0 200 89.5 200 200s-89.5 200 -200 200s-200 -89.5 -200 -200s89.5 -200 200 -200zM317.8 96.4004l-84.8994 61.6992
|
||||
c-3.10059 2.30078 -4.90039 5.90039 -4.90039 9.7002v164.2c0 6.59961 5.40039 12 12 12h32c6.59961 0 12 -5.40039 12 -12v-141.7l66.7998 -48.5996c5.40039 -3.90039 6.5 -11.4004 2.60059 -16.7998l-18.8008 -25.9004c-3.89941 -5.2998 -11.3994 -6.5 -16.7998 -2.59961z
|
||||
" />
|
||||
<glyph glyph-name="list-alt" unicode=""
|
||||
d="M464 416c26.5098 0 48 -21.4902 48 -48v-352c0 -26.5098 -21.4902 -48 -48 -48h-416c-26.5098 0 -48 21.4902 -48 48v352c0 26.5098 21.4902 48 48 48h416zM458 16c3.31152 0 6 2.68848 6 6v340c0 3.31152 -2.68848 6 -6 6h-404c-3.31152 0 -6 -2.68848 -6 -6v-340
|
||||
c0 -3.31152 2.68848 -6 6 -6h404zM416 108v-24c0 -6.62695 -5.37305 -12 -12 -12h-200c-6.62695 0 -12 5.37305 -12 12v24c0 6.62695 5.37305 12 12 12h200c6.62695 0 12 -5.37305 12 -12zM416 204v-24c0 -6.62695 -5.37305 -12 -12 -12h-200c-6.62695 0 -12 5.37305 -12 12
|
||||
v24c0 6.62695 5.37305 12 12 12h200c6.62695 0 12 -5.37305 12 -12zM416 300v-24c0 -6.62695 -5.37305 -12 -12 -12h-200c-6.62695 0 -12 5.37305 -12 12v24c0 6.62695 5.37305 12 12 12h200c6.62695 0 12 -5.37305 12 -12zM164 288c0 -19.8818 -16.1182 -36 -36 -36
|
||||
s-36 16.1182 -36 36s16.1182 36 36 36s36 -16.1182 36 -36zM164 192c0 -19.8818 -16.1182 -36 -36 -36s-36 16.1182 -36 36s16.1182 36 36 36s36 -16.1182 36 -36zM164 96c0 -19.8818 -16.1182 -36 -36 -36s-36 16.1182 -36 36s16.1182 36 36 36s36 -16.1182 36 -36z" />
|
||||
<glyph glyph-name="flag" unicode=""
|
||||
d="M336.174 368c35.4668 0 73.0195 12.6914 108.922 28.1797c31.6406 13.6514 66.9043 -9.65723 66.9043 -44.1162v-239.919c0 -16.1953 -8.1543 -31.3057 -21.7129 -40.1631c-26.5762 -17.3643 -70.0693 -39.9814 -128.548 -39.9814c-68.6084 0 -112.781 32 -161.913 32
|
||||
c-56.5674 0 -89.957 -11.2803 -127.826 -28.5566v-83.4434c0 -8.83691 -7.16309 -16 -16 -16h-16c-8.83691 0 -16 7.16309 -16 16v406.438c-14.3428 8.2998 -24 23.7979 -24 41.5615c0 27.5693 23.2422 49.71 51.2012 47.8965
|
||||
c22.9658 -1.49023 41.8662 -19.4717 44.4805 -42.3379c0.213867 -1.83398 0.308594 -3.65918 0.308594 -5.5498c0 -5.30273 -0.860352 -10.4053 -2.4502 -15.1768c22.418 8.68555 49.4199 15.168 80.7207 15.168c68.6084 0 112.781 -32 161.913 -32zM464 112v240
|
||||
c-31.5059 -14.6338 -84.5547 -32 -127.826 -32c-59.9111 0 -101.968 32 -161.913 32c-41.4365 0 -80.4766 -16.5879 -102.261 -32v-232c31.4473 14.5967 84.4648 24 127.826 24c59.9111 0 101.968 -32 161.913 -32c41.4365 0 80.4775 16.5879 102.261 32z" />
|
||||
<glyph glyph-name="bookmark" unicode="" horiz-adv-x="384"
|
||||
d="M336 448c26.5098 0 48 -21.4902 48 -48v-464l-192 112l-192 -112v464c0 26.5098 21.4902 48 48 48h288zM336 19.5703v374.434c0 3.31348 -2.68555 5.99609 -6 5.99609h-276c-3.31152 0 -6 -2.68848 -6 -6v-374.43l144 84z" />
|
||||
<glyph glyph-name="image" unicode=""
|
||||
d="M464 384c26.5098 0 48 -21.4902 48 -48v-288c0 -26.5098 -21.4902 -48 -48 -48h-416c-26.5098 0 -48 21.4902 -48 48v288c0 26.5098 21.4902 48 48 48h416zM458 48c3.31152 0 6 2.68848 6 6v276c0 3.31152 -2.68848 6 -6 6h-404c-3.31152 0 -6 -2.68848 -6 -6v-276
|
||||
c0 -3.31152 2.68848 -6 6 -6h404zM128 296c22.0908 0 40 -17.9092 40 -40s-17.9092 -40 -40 -40s-40 17.9092 -40 40s17.9092 40 40 40zM96 96v48l39.5137 39.5146c4.6875 4.68652 12.2852 4.68652 16.9717 0l39.5146 -39.5146l119.514 119.515
|
||||
c4.6875 4.68652 12.2852 4.68652 16.9717 0l87.5146 -87.5146v-80h-320z" />
|
||||
<glyph glyph-name="edit" unicode="" horiz-adv-x="576"
|
||||
d="M402.3 103.1l32 32c5 5 13.7002 1.5 13.7002 -5.69922v-145.4c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h273.5c7.09961 0 10.7002 -8.59961 5.7002 -13.7002l-32 -32c-1.5 -1.5 -3.5 -2.2998 -5.7002 -2.2998h-241.5v-352h352
|
||||
v113.5c0 2.09961 0.799805 4.09961 2.2998 5.59961zM558.9 304.9l-262.601 -262.601l-90.3994 -10c-26.2002 -2.89941 -48.5 19.2002 -45.6006 45.6006l10 90.3994l262.601 262.601c22.8994 22.8994 59.8994 22.8994 82.6992 0l43.2002 -43.2002
|
||||
c22.9004 -22.9004 22.9004 -60 0.100586 -82.7998zM460.1 274l-58.0996 58.0996l-185.8 -185.899l-7.2998 -65.2998l65.2998 7.2998zM524.9 353.7l-43.2002 43.2002c-4.10059 4.09961 -10.7998 4.09961 -14.7998 0l-30.9004 -30.9004l58.0996 -58.0996l30.9004 30.8994
|
||||
c4 4.2002 4 10.7998 -0.0996094 14.9004z" />
|
||||
<glyph glyph-name="times-circle" unicode=""
|
||||
d="M256 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM256 -8c110.5 0 200 89.5 200 200s-89.5 200 -200 200s-200 -89.5 -200 -200s89.5 -200 200 -200zM357.8 254.2l-62.2002 -62.2002l62.2002 -62.2002
|
||||
c4.7002 -4.7002 4.7002 -12.2998 0 -17l-22.5996 -22.5996c-4.7002 -4.7002 -12.2998 -4.7002 -17 0l-62.2002 62.2002l-62.2002 -62.2002c-4.7002 -4.7002 -12.2998 -4.7002 -17 0l-22.5996 22.5996c-4.7002 4.7002 -4.7002 12.2998 0 17l62.2002 62.2002l-62.2002 62.2002
|
||||
c-4.7002 4.7002 -4.7002 12.2998 0 17l22.5996 22.5996c4.7002 4.7002 12.2998 4.7002 17 0l62.2002 -62.2002l62.2002 62.2002c4.7002 4.7002 12.2998 4.7002 17 0l22.5996 -22.5996c4.7002 -4.7002 4.7002 -12.2998 0 -17z" />
|
||||
<glyph glyph-name="check-circle" unicode=""
|
||||
d="M256 440c136.967 0 248 -111.033 248 -248s-111.033 -248 -248 -248s-248 111.033 -248 248s111.033 248 248 248zM256 392c-110.549 0 -200 -89.4678 -200 -200c0 -110.549 89.4678 -200 200 -200c110.549 0 200 89.4678 200 200c0 110.549 -89.4678 200 -200 200z
|
||||
M396.204 261.733c4.66699 -4.70508 4.63672 -12.3037 -0.0673828 -16.9717l-172.589 -171.204c-4.70508 -4.66797 -12.3027 -4.63672 -16.9697 0.0683594l-90.7812 91.5156c-4.66797 4.70605 -4.63672 12.3047 0.0683594 16.9717l22.7188 22.5361
|
||||
c4.70508 4.66699 12.3027 4.63574 16.9697 -0.0693359l59.792 -60.2773l141.353 140.216c4.70508 4.66797 12.3027 4.6377 16.9697 -0.0673828z" />
|
||||
<glyph glyph-name="question-circle" unicode=""
|
||||
d="M256 440c136.957 0 248 -111.083 248 -248c0 -136.997 -111.043 -248 -248 -248s-248 111.003 -248 248c0 136.917 111.043 248 248 248zM256 -8c110.569 0 200 89.4697 200 200c0 110.529 -89.5088 200 -200 200c-110.528 0 -200 -89.5049 -200 -200
|
||||
c0 -110.569 89.4678 -200 200 -200zM363.244 247.2c0 -67.0518 -72.4209 -68.084 -72.4209 -92.8633v-6.33691c0 -6.62695 -5.37305 -12 -12 -12h-45.6475c-6.62695 0 -12 5.37305 -12 12v8.65918c0 35.7451 27.1006 50.0342 47.5791 61.5156
|
||||
c17.5615 9.84473 28.3242 16.541 28.3242 29.5791c0 17.2461 -21.999 28.6934 -39.7842 28.6934c-23.1885 0 -33.8936 -10.9775 -48.9424 -29.9697c-4.05664 -5.11914 -11.46 -6.07031 -16.666 -2.12402l-27.8232 21.0986
|
||||
c-5.10742 3.87207 -6.25098 11.0654 -2.64453 16.3633c23.627 34.6934 53.7217 54.1846 100.575 54.1846c49.0713 0 101.45 -38.3037 101.45 -88.7998zM298 80c0 -23.1592 -18.8408 -42 -42 -42s-42 18.8408 -42 42s18.8408 42 42 42s42 -18.8408 42 -42z" />
|
||||
<glyph glyph-name="eye" unicode="" horiz-adv-x="576"
|
||||
d="M288 304c0.114258 0 0.240234 -0.0175781 0.354492 -0.0175781c61.6543 0 111.71 -50.0557 111.71 -111.71s-50.0557 -111.71 -111.71 -111.71s-111.71 50.0557 -111.71 111.71c0 10.7422 1.51953 21.1328 4.35547 30.9678
|
||||
c7.95898 -4.52637 17.2129 -7.17188 27 -7.24023c30.9072 0 56 25.0928 56 56c-0.0683594 9.78711 -2.71387 19.041 -7.24023 27c9.88379 3.07617 20.3896 4.83008 31.2402 5zM572.52 206.6c2.21387 -4.37793 3.46094 -9.38965 3.46094 -14.626
|
||||
c0 -5.2373 -1.24707 -10.1855 -3.46094 -14.5635c-54.1992 -105.771 -161.59 -177.41 -284.52 -177.41s-230.29 71.5898 -284.52 177.4c-2.21387 4.37793 -3.46094 9.38965 -3.46094 14.626c0 5.2373 1.24707 10.1855 3.46094 14.5635
|
||||
c54.1992 105.771 161.59 177.41 284.52 177.41s230.29 -71.5898 284.52 -177.4zM288 48c98.6602 0 189.1 55 237.93 144c-48.8398 89 -139.27 144 -237.93 144s-189.09 -55 -237.93 -144c48.8398 -89 139.279 -144 237.93 -144z" />
|
||||
<glyph glyph-name="eye-slash" unicode="" horiz-adv-x="640"
|
||||
d="M634 -23c3.66895 -2.93262 6.00391 -7.45117 6.00391 -12.5088c0 -3.7832 -1.31543 -7.26074 -3.51367 -10.001l-10 -12.4902c-2.93359 -3.66309 -7.44824 -5.99414 -12.502 -5.99414c-3.77637 0 -7.25 1.31152 -9.98828 3.50391l-598 467.49
|
||||
c-3.66895 2.93262 -6.00391 7.45117 -6.00391 12.5088c0 3.7832 1.31543 7.26074 3.51367 10.001l10 12.4902c2.93359 3.66309 7.44824 5.99414 12.502 5.99414c3.77637 0 7.25 -1.31152 9.98828 -3.50391zM296.79 301.53c7.51172 1.60254 15.2266 2.45508 23.21 2.46973
|
||||
c60.4805 0 109.36 -47.9102 111.58 -107.85zM343.21 82.46c-7.51367 -1.59375 -15.2285 -2.44336 -23.21 -2.45996c-60.4697 0 -109.35 47.9102 -111.58 107.84zM320 336c-19.8799 0 -39.2803 -2.7998 -58.2197 -7.09961l-46.4102 36.29
|
||||
c32.9199 11.8096 67.9297 18.8096 104.63 18.8096c122.93 0 230.29 -71.5898 284.57 -177.4c2.21289 -4.37793 3.45996 -9.38965 3.45996 -14.626c0 -5.2373 -1.24707 -10.1855 -3.45996 -14.5635c-14.1924 -27.5625 -31.9229 -52.6689 -52.9004 -75.1104l-37.7402 29.5
|
||||
c17.2305 18.0527 31.9385 38.1318 44 60.2002c-48.8398 89 -139.279 144 -237.93 144zM320 48c19.8896 0 39.2803 2.7998 58.2197 7.08984l46.4102 -36.2803c-32.9199 -11.7598 -67.9297 -18.8096 -104.63 -18.8096c-122.92 0 -230.28 71.5898 -284.51 177.4
|
||||
c-2.21387 4.37793 -3.46094 9.38965 -3.46094 14.626c0 5.2373 1.24707 10.1855 3.46094 14.5635c14.1885 27.5586 31.916 52.6621 52.8896 75.1006l37.7402 -29.5c-17.249 -18.0469 -31.9727 -38.1221 -44.0498 -60.1904c48.8496 -89 139.279 -144 237.93 -144z" />
|
||||
<glyph glyph-name="calendar-alt" unicode="" horiz-adv-x="448"
|
||||
d="M148 160h-40c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40c0 -6.59961 -5.40039 -12 -12 -12zM256 172c0 -6.59961 -5.40039 -12 -12 -12h-40c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40
|
||||
c6.59961 0 12 -5.40039 12 -12v-40zM352 172c0 -6.59961 -5.40039 -12 -12 -12h-40c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40zM256 76c0 -6.59961 -5.40039 -12 -12 -12h-40c-6.59961 0 -12 5.40039 -12 12v40
|
||||
c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40zM160 76c0 -6.59961 -5.40039 -12 -12 -12h-40c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40zM352 76c0 -6.59961 -5.40039 -12 -12 -12h-40
|
||||
c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40zM448 336v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h48v52c0 6.59961 5.40039 12 12 12h40
|
||||
c6.59961 0 12 -5.40039 12 -12v-52h128v52c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-52h48c26.5 0 48 -21.5 48 -48zM400 -10v298h-352v-298c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="comment" unicode=""
|
||||
d="M256 416c141.4 0 256 -93.0996 256 -208s-114.6 -208 -256 -208c-32.7998 0 -64 5.2002 -92.9004 14.2998c-29.0996 -20.5996 -77.5996 -46.2998 -139.1 -46.2998c-9.59961 0 -18.2998 5.7002 -22.0996 14.5c-3.80078 8.7998 -2 19 4.59961 26
|
||||
c0.5 0.400391 31.5 33.7998 46.4004 73.2002c-33 35.0996 -52.9004 78.7002 -52.9004 126.3c0 114.9 114.6 208 256 208zM256 48c114.7 0 208 71.7998 208 160s-93.2998 160 -208 160s-208 -71.7998 -208 -160c0 -42.2002 21.7002 -74.0996 39.7998 -93.4004
|
||||
l20.6006 -21.7998l-10.6006 -28.0996c-5.5 -14.5 -12.5996 -28.1006 -19.8994 -40.2002c23.5996 7.59961 43.1992 18.9004 57.5 29l19.5 13.7998l22.6992 -7.2002c25.3008 -8 51.7002 -12.0996 78.4004 -12.0996z" />
|
||||
<glyph glyph-name="folder" unicode=""
|
||||
d="M464 320c26.5098 0 48 -21.4902 48 -48v-224c0 -26.5098 -21.4902 -48 -48 -48h-416c-26.5098 0 -48 21.4902 -48 48v288c0 26.5098 21.4902 48 48 48h146.74c8.49023 0 16.6299 -3.37012 22.6299 -9.37012l54.6299 -54.6299h192zM464 48v224h-198.62
|
||||
c-8.49023 0 -16.6299 3.37012 -22.6299 9.37012l-54.6299 54.6299h-140.12v-288h416z" />
|
||||
<glyph glyph-name="folder-open" unicode="" horiz-adv-x="576"
|
||||
d="M527.9 224c37.6992 0 60.6992 -41.5 40.6992 -73.4004l-79.8994 -128c-8.7998 -14.0996 -24.2002 -22.5996 -40.7002 -22.5996h-400c-26.5 0 -48 21.5 -48 48v288c0 26.5 21.5 48 48 48h160l64 -64h160c26.5 0 48 -21.5 48 -48v-48h47.9004zM48 330v-233.4l62.9004 104.2
|
||||
c8.69922 14.4004 24.2998 23.2002 41.0996 23.2002h280v42c0 3.2998 -2.7002 6 -6 6h-173.9l-64 64h-134.1c-3.2998 0 -6 -2.7002 -6 -6zM448 48l80 128h-378.8l-77.2002 -128h376z" />
|
||||
<glyph glyph-name="chart-bar" unicode=""
|
||||
d="M396.8 96c-6.39941 0 -12.7998 6.40039 -12.7998 12.7998v230.4c0 6.39941 6.40039 12.7998 12.7998 12.7998h22.4004c6.39941 0 12.7998 -6.40039 12.7998 -12.7998v-230.4c0 -6.39941 -6.40039 -12.7998 -12.7998 -12.7998h-22.4004zM204.8 96
|
||||
c-6.39941 0 -12.7998 6.40039 -12.7998 12.7998v198.4c0 6.39941 6.40039 12.7998 12.7998 12.7998h22.4004c6.39941 0 12.7998 -6.40039 12.7998 -12.7998v-198.4c0 -6.39941 -6.40039 -12.7998 -12.7998 -12.7998h-22.4004zM300.8 96
|
||||
c-6.39941 0 -12.7998 6.40039 -12.7998 12.7998v134.4c0 6.39941 6.40039 12.7998 12.7998 12.7998h22.4004c6.39941 0 12.7998 -6.40039 12.7998 -12.7998v-134.4c0 -6.39941 -6.40039 -12.7998 -12.7998 -12.7998h-22.4004zM496 48c8.83984 0 16 -7.16016 16 -16v-16
|
||||
c0 -8.83984 -7.16016 -16 -16 -16h-464c-17.6699 0 -32 14.3301 -32 32v336c0 8.83984 7.16016 16 16 16h16c8.83984 0 16 -7.16016 16 -16v-320h448zM108.8 96c-6.39941 0 -12.7998 6.40039 -12.7998 12.7998v70.4004c0 6.39941 6.40039 12.7998 12.7998 12.7998h22.4004
|
||||
c6.39941 0 12.7998 -6.40039 12.7998 -12.7998v-70.4004c0 -6.39941 -6.40039 -12.7998 -12.7998 -12.7998h-22.4004z" />
|
||||
<glyph glyph-name="comments" unicode="" horiz-adv-x="576"
|
||||
d="M532 61.7998c15.2998 -30.7002 37.4004 -54.5 37.7998 -54.7998c6.2998 -6.7002 8 -16.5 4.40039 -25c-3.7002 -8.5 -12 -14 -21.2002 -14c-53.5996 0 -96.7002 20.2998 -125.2 38.7998c-19 -4.39941 -39 -6.7998 -59.7998 -6.7998
|
||||
c-86.2002 0 -159.9 40.4004 -191.3 97.7998c-9.7002 1.2002 -19.2002 2.7998 -28.4004 4.90039c-28.5 -18.6006 -71.7002 -38.7998 -125.2 -38.7998c-9.19922 0 -17.5996 5.5 -21.1992 14c-3.7002 8.5 -1.90039 18.2998 4.39941 25
|
||||
c0.400391 0.399414 22.4004 24.1992 37.7002 54.8994c-27.5 27.2002 -44 61.2002 -44 98.2002c0 88.4004 93.0996 160 208 160c86.2998 0 160.3 -40.5 191.8 -98.0996c99.7002 -11.8008 176.2 -77.9004 176.2 -157.9c0 -37.0996 -16.5 -71.0996 -44 -98.2002zM139.2 154.1
|
||||
l19.7998 -4.5c16 -3.69922 32.5 -5.59961 49 -5.59961c86.7002 0 160 51.2998 160 112s-73.2998 112 -160 112s-160 -51.2998 -160 -112c0 -28.7002 16.2002 -50.5996 29.7002 -64l24.7998 -24.5l-15.5 -31.0996c-2.59961 -5.10059 -5.2998 -10.1006 -8 -14.8008
|
||||
c14.5996 5.10059 29 12.3008 43.0996 21.4004zM498.3 96c13.5 13.4004 29.7002 35.2998 29.7002 64c0 49.2002 -48.2998 91.5 -112.7 106c0.299805 -3.2998 0.700195 -6.59961 0.700195 -10c0 -80.9004 -78 -147.5 -179.3 -158.3
|
||||
c29.0996 -29.6006 77.2998 -49.7002 131.3 -49.7002c16.5 0 33 1.90039 49 5.59961l19.9004 4.60059l17.0996 -11.1006c14.0996 -9.09961 28.5 -16.2998 43.0996 -21.3994c-2.69922 4.7002 -5.39941 9.7002 -8 14.7998l-15.5 31.0996z" />
|
||||
<glyph glyph-name="star-half" unicode="" horiz-adv-x="576"
|
||||
d="M288 62.7002v-54.2998l-130.7 -68.6006c-23.3994 -12.2998 -50.8994 7.60059 -46.3994 33.7002l25 145.5l-105.7 103c-19 18.5 -8.5 50.7998 17.7002 54.5996l146.1 21.2002l65.2998 132.4c5.90039 11.8994 17.2998 17.7998 28.7002 17.7998v-68.0996l-62.2002 -126
|
||||
l-139 -20.2002l100.601 -98l-23.7002 -138.4z" />
|
||||
<glyph glyph-name="lemon" unicode=""
|
||||
d="M484.112 420.111c28.1221 -28.123 35.9434 -68.0039 19.0215 -97.0547c-23.0576 -39.584 50.1436 -163.384 -82.3311 -295.86c-132.301 -132.298 -256.435 -59.3594 -295.857 -82.3291c-29.0459 -16.917 -68.9219 -9.11426 -97.0576 19.0205
|
||||
c-28.1221 28.1221 -35.9434 68.0029 -19.0215 97.0547c23.0566 39.5859 -50.1436 163.386 82.3301 295.86c132.308 132.309 256.407 59.3496 295.862 82.332c29.0498 16.9219 68.9307 9.09863 97.0537 -19.0234zM461.707 347.217
|
||||
c13.5166 23.2031 -27.7578 63.7314 -50.4883 50.4912c-66.6025 -38.7939 -165.646 45.5898 -286.081 -74.8457c-120.444 -120.445 -36.0449 -219.472 -74.8447 -286.08c-13.542 -23.2471 27.8145 -63.6953 50.4932 -50.4883
|
||||
c66.6006 38.7949 165.636 -45.5996 286.076 74.8428c120.444 120.445 36.0449 219.472 74.8447 286.08zM291.846 338.481c1.37012 -10.96 -6.40332 -20.957 -17.3643 -22.3271c-54.8467 -6.85547 -135.779 -87.7871 -142.636 -142.636
|
||||
c-1.37305 -10.9883 -11.3984 -18.7334 -22.3262 -17.3643c-10.9609 1.37012 -18.7344 11.3652 -17.3643 22.3262c9.16211 73.2852 104.167 168.215 177.364 177.364c10.9531 1.36816 20.9561 -6.40234 22.3262 -17.3633z" />
|
||||
<glyph glyph-name="credit-card" unicode="" horiz-adv-x="576"
|
||||
d="M527.9 416c26.5996 0 48.0996 -21.5 48.0996 -48v-352c0 -26.5 -21.5 -48 -48.0996 -48h-479.801c-26.5996 0 -48.0996 21.5 -48.0996 48v352c0 26.5 21.5 48 48.0996 48h479.801zM54.0996 368c-3.2998 0 -6 -2.7002 -6 -6v-42h479.801v42c0 3.2998 -2.7002 6 -6 6
|
||||
h-467.801zM521.9 16c3.2998 0 6 2.7002 6 6v170h-479.801v-170c0 -3.2998 2.7002 -6 6 -6h467.801zM192 116v-40c0 -6.59961 -5.40039 -12 -12 -12h-72c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h72c6.59961 0 12 -5.40039 12 -12zM384 116v-40
|
||||
c0 -6.59961 -5.40039 -12 -12 -12h-136c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h136c6.59961 0 12 -5.40039 12 -12z" />
|
||||
<glyph glyph-name="hdd" unicode="" horiz-adv-x="576"
|
||||
d="M567.403 212.358c5.59668 -8.04688 8.59668 -17.6113 8.59668 -27.4121v-136.946c0 -26.5098 -21.4902 -48 -48 -48h-480c-26.5098 0 -48 21.4902 -48 48v136.946c0 10.167 3.19531 19.6465 8.59668 27.4121l105.08 151.053
|
||||
c8.67383 12.4678 23.0791 20.5889 39.4043 20.5889h269.838c16.3252 0 30.7305 -8.12109 39.4043 -20.5889zM153.081 336l-77.9131 -112h425.664l-77.9131 112h-269.838zM528 48v128h-480v-128h480zM496 112c0 -17.6729 -14.3271 -32 -32 -32s-32 14.3271 -32 32
|
||||
s14.3271 32 32 32s32 -14.3271 32 -32zM400 112c0 -17.6729 -14.3271 -32 -32 -32s-32 14.3271 -32 32s14.3271 32 32 32s32 -14.3271 32 -32z" />
|
||||
<glyph glyph-name="hand-point-right" unicode=""
|
||||
d="M428.8 310.4c45.0996 0 83.2002 -38.1016 83.2002 -83.2002c0 -45.6162 -37.7646 -83.2002 -83.2002 -83.2002h-35.6475c-1.71387 -7.70605 -4.43555 -15.2051 -7.92969 -22.0645c2.50586 -22.0059 -3.50293 -44.9775 -15.9844 -62.791
|
||||
c-1.14062 -52.4863 -37.3984 -91.1445 -99.9404 -91.1445h-21.2988c-60.0635 0 -98.5117 40 -127.2 40h-2.67871c-5.74707 -4.95215 -13.5361 -8 -22.1201 -8h-64c-17.6729 0 -32 12.8936 -32 28.7998v230.4c0 15.9062 14.3271 28.7998 32 28.7998h64.001
|
||||
c8.58398 0 16.373 -3.04785 22.1201 -8h2.67871c6.96387 0 14.8623 6.19336 30.1816 23.6689l0.128906 0.148438l0.130859 0.145508c8.85645 9.93652 18.1162 20.8398 25.8506 33.2529c18.7051 30.2471 30.3936 78.7842 75.707 78.7842c56.9277 0 92 -35.2861 92 -83.2002
|
||||
c0 -0.0283203 0 0.0361328 0 0.0078125c0 -7.66602 -0.748047 -15.1582 -2.17578 -22.4072h86.1768zM428.8 192c18.9756 0 35.2002 16.2246 35.2002 35.2002c0 18.7002 -16.7754 35.2002 -35.2002 35.2002h-158.399c0 17.3242 26.3994 35.1992 26.3994 70.3994
|
||||
c0 26.4004 -20.625 35.2002 -44 35.2002c-8.79395 0 -20.4443 -32.7119 -34.9258 -56.0996c-9.07422 -14.5752 -19.5244 -27.2256 -30.7988 -39.875c-16.1094 -18.374 -33.8359 -36.6328 -59.0752 -39.5967v-176.753c42.79 -3.7627 74.5088 -39.6758 120 -39.6758h21.2988
|
||||
c40.5244 0 57.124 22.1973 50.6006 61.3252c14.6113 8.00098 24.1514 33.9785 12.9248 53.625c19.3652 18.2246 17.7871 46.3809 4.9502 61.0498h91.0254zM88 64c0 13.2549 -10.7451 24 -24 24s-24 -10.7451 -24 -24s10.7451 -24 24 -24s24 10.7451 24 24z" />
|
||||
<glyph glyph-name="hand-point-left" unicode=""
|
||||
d="M0 227.2c0 45.0986 38.1006 83.2002 83.2002 83.2002h86.1758c-1.3623 6.91016 -2.17578 14.374 -2.17578 22.3994c0 47.9141 35.0723 83.2002 92 83.2002c45.3135 0 57.002 -48.5371 75.7061 -78.7852c7.73438 -12.4121 16.9951 -23.3154 25.8506 -33.2529
|
||||
l0.130859 -0.145508l0.128906 -0.148438c15.3213 -17.4746 23.2197 -23.668 30.1836 -23.668h2.67871c5.74707 4.95215 13.5361 8 22.1201 8h64c17.6729 0 32 -12.8936 32 -28.7998v-230.4c0 -15.9062 -14.3271 -28.7998 -32 -28.7998h-64
|
||||
c-8.58398 0 -16.373 3.04785 -22.1201 8h-2.67871c-28.6885 0 -67.1367 -40 -127.2 -40h-21.2988c-62.542 0 -98.8008 38.6582 -99.9404 91.1445c-12.4814 17.8135 -18.4922 40.7852 -15.9844 62.791c-3.49414 6.85938 -6.21582 14.3584 -7.92969 22.0645h-35.6465
|
||||
c-45.4355 0 -83.2002 37.584 -83.2002 83.2002zM48 227.2c0 -18.9756 16.2246 -35.2002 35.2002 -35.2002h91.0244c-12.8369 -14.6689 -14.415 -42.8252 4.9502 -61.0498c-11.2256 -19.6465 -1.68652 -45.624 12.9248 -53.625
|
||||
c-6.52246 -39.1279 10.0771 -61.3252 50.6016 -61.3252h21.2988c45.4912 0 77.21 35.9131 120 39.6768v176.752c-25.2393 2.96289 -42.9658 21.2227 -59.0752 39.5967c-11.2744 12.6494 -21.7246 25.2998 -30.7988 39.875
|
||||
c-14.4814 23.3877 -26.1318 56.0996 -34.9258 56.0996c-23.375 0 -44 -8.7998 -44 -35.2002c0 -35.2002 26.3994 -53.0752 26.3994 -70.3994h-158.399c-18.4248 0 -35.2002 -16.5 -35.2002 -35.2002zM448 88c-13.2549 0 -24 -10.7451 -24 -24s10.7451 -24 24 -24
|
||||
s24 10.7451 24 24s-10.7451 24 -24 24z" />
|
||||
<glyph glyph-name="hand-point-up" unicode="" horiz-adv-x="448"
|
||||
d="M105.6 364.8c0 45.0996 38.1016 83.2002 83.2002 83.2002c45.6162 0 83.2002 -37.7646 83.2002 -83.2002v-35.6465c7.70605 -1.71387 15.2051 -4.43555 22.0645 -7.92969c22.0059 2.50684 44.9775 -3.50293 62.791 -15.9844
|
||||
c52.4863 -1.14062 91.1445 -37.3984 91.1445 -99.9404v-21.2988c0 -60.0635 -40 -98.5117 -40 -127.2v-2.67871c4.95215 -5.74707 8 -13.5361 8 -22.1201v-64c0 -17.6729 -12.8936 -32 -28.7998 -32h-230.4c-15.9062 0 -28.7998 14.3271 -28.7998 32v64
|
||||
c0 8.58398 3.04785 16.373 8 22.1201v2.67871c0 6.96387 -6.19336 14.8623 -23.6689 30.1816l-0.148438 0.128906l-0.145508 0.130859c-9.93652 8.85645 -20.8398 18.1162 -33.2529 25.8506c-30.2471 18.7051 -78.7842 30.3936 -78.7842 75.707
|
||||
c0 56.9277 35.2861 92 83.2002 92c0.0283203 0 -0.0361328 0 -0.0078125 0c7.66602 0 15.1582 -0.748047 22.4072 -2.17578v86.1768zM224 364.8c0 18.9756 -16.2246 35.2002 -35.2002 35.2002c-18.7002 0 -35.2002 -16.7754 -35.2002 -35.2002v-158.399
|
||||
c-17.3242 0 -35.1992 26.3994 -70.3994 26.3994c-26.4004 0 -35.2002 -20.625 -35.2002 -44c0 -8.79395 32.7119 -20.4443 56.0996 -34.9258c14.5752 -9.07422 27.2256 -19.5244 39.875 -30.7988c18.374 -16.1094 36.6328 -33.8359 39.5967 -59.0752h176.753
|
||||
c3.7627 42.79 39.6758 74.5088 39.6758 120v21.2988c0 40.5244 -22.1973 57.124 -61.3252 50.6006c-8.00098 14.6113 -33.9785 24.1514 -53.625 12.9248c-18.2246 19.3652 -46.3809 17.7871 -61.0498 4.9502v91.0254zM352 24c-13.2549 0 -24 -10.7451 -24 -24
|
||||
s10.7451 -24 24 -24s24 10.7451 24 24s-10.7451 24 -24 24z" />
|
||||
<glyph glyph-name="hand-point-down" unicode="" horiz-adv-x="448"
|
||||
d="M188.8 -64c-45.0986 0 -83.2002 38.1006 -83.2002 83.2002v86.1758c-6.91016 -1.3623 -14.374 -2.17578 -22.3994 -2.17578c-47.9141 0 -83.2002 35.0723 -83.2002 92c0 45.3135 48.5371 57.002 78.7852 75.707c12.4121 7.73438 23.3154 16.9951 33.2529 25.8506
|
||||
l0.145508 0.130859l0.148438 0.128906c17.4746 15.3213 23.668 23.2197 23.668 30.1836v2.67871c-4.95215 5.74707 -8 13.5361 -8 22.1201v64c0 17.6729 12.8936 32 28.7998 32h230.4c15.9062 0 28.7998 -14.3271 28.7998 -32v-64.001
|
||||
c0 -8.58398 -3.04785 -16.373 -8 -22.1201v-2.67871c0 -28.6885 40 -67.1367 40 -127.2v-21.2988c0 -62.542 -38.6582 -98.8008 -91.1445 -99.9404c-17.8135 -12.4814 -40.7852 -18.4922 -62.791 -15.9844c-6.85938 -3.49414 -14.3584 -6.21582 -22.0645 -7.92969v-35.6465
|
||||
c0 -45.4355 -37.584 -83.2002 -83.2002 -83.2002zM188.8 -16c18.9756 0 35.2002 16.2246 35.2002 35.2002v91.0244c14.6689 -12.8369 42.8252 -14.415 61.0498 4.9502c19.6465 -11.2256 45.624 -1.68652 53.625 12.9248c39.1279 -6.52246 61.3252 10.0771 61.3252 50.6016
|
||||
v21.2988c0 45.4912 -35.9131 77.21 -39.6768 120h-176.752c-2.96289 -25.2393 -21.2227 -42.9658 -39.5967 -59.0752c-12.6494 -11.2744 -25.2998 -21.7246 -39.875 -30.7988c-23.3877 -14.4814 -56.0996 -26.1318 -56.0996 -34.9258c0 -23.375 8.7998 -44 35.2002 -44
|
||||
c35.2002 0 53.0752 26.3994 70.3994 26.3994v-158.399c0 -18.4248 16.5 -35.2002 35.2002 -35.2002zM328 384c0 -13.2549 10.7451 -24 24 -24s24 10.7451 24 24s-10.7451 24 -24 24s-24 -10.7451 -24 -24z" />
|
||||
<glyph glyph-name="copy" unicode="" horiz-adv-x="448"
|
||||
d="M433.941 382.059c8.68848 -8.68848 14.0586 -20.6943 14.0586 -33.9404v-268.118c0 -26.5098 -21.4902 -48 -48 -48h-80v-48c0 -26.5098 -21.4902 -48 -48 -48h-224c-26.5098 0 -48 21.4902 -48 48v320c0 26.5098 21.4902 48 48 48h80v48c0 26.5098 21.4902 48 48 48
|
||||
h172.118c13.2461 0 25.252 -5.37012 33.9404 -14.0586zM266 -16c3.31152 0 6 2.68848 6 6v42h-96c-26.5098 0 -48 21.4902 -48 48v224h-74c-3.31152 0 -6 -2.68848 -6 -6v-308c0 -3.31152 2.68848 -6 6 -6h212zM394 80c3.31152 0 6 2.68848 6 6v202h-88
|
||||
c-13.2549 0 -24 10.7451 -24 24v88h-106c-3.31152 0 -6 -2.68848 -6 -6v-308c0 -3.31152 2.68848 -6 6 -6h212zM400 336v9.63184c0 1.65527 -0.670898 3.15723 -1.75684 4.24316l-48.3682 48.3682c-1.12598 1.125 -2.65234 1.75684 -4.24316 1.75684h-9.63184v-64h64z" />
|
||||
<glyph glyph-name="save" unicode="" horiz-adv-x="448"
|
||||
d="M433.941 318.059c8.68848 -8.68848 14.0586 -20.6943 14.0586 -33.9404v-268.118c0 -26.5098 -21.4902 -48 -48 -48h-352c-26.5098 0 -48 21.4902 -48 48v352c0 26.5098 21.4902 48 48 48h268.118c13.2461 0 25.252 -5.37012 33.9404 -14.0586zM272 368h-128v-80h128v80z
|
||||
M394 16c3.31152 0 6 2.68848 6 6v259.632c0 1.65527 -0.670898 3.15723 -1.75684 4.24316l-78.2432 78.2432v-100.118c0 -13.2549 -10.7451 -24 -24 -24h-176c-13.2549 0 -24 10.7451 -24 24v104h-42c-3.31152 0 -6 -2.68848 -6 -6v-340c0 -3.31152 2.68848 -6 6 -6h340z
|
||||
M224 216c48.5234 0 88 -39.4766 88 -88s-39.4766 -88 -88 -88s-88 39.4766 -88 88s39.4766 88 88 88zM224 88c22.0557 0 40 17.9443 40 40s-17.9443 40 -40 40s-40 -17.9443 -40 -40s17.9443 -40 40 -40z" />
|
||||
<glyph glyph-name="square" unicode="" horiz-adv-x="448"
|
||||
d="M400 416c26.5 0 48 -21.5 48 -48v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h352zM394 16c3.2998 0 6 2.7002 6 6v340c0 3.2998 -2.7002 6 -6 6h-340c-3.2998 0 -6 -2.7002 -6 -6v-340c0 -3.2998 2.7002 -6 6 -6h340z" />
|
||||
<glyph glyph-name="envelope" unicode=""
|
||||
d="M464 384c26.5098 0 48 -21.4902 48 -48v-288c0 -26.5098 -21.4902 -48 -48 -48h-416c-26.5098 0 -48 21.4902 -48 48v288c0 26.5098 21.4902 48 48 48h416zM464 336h-416v-40.8047c22.4248 -18.2627 58.1797 -46.6602 134.587 -106.49
|
||||
c16.834 -13.2422 50.2051 -45.0762 73.4131 -44.7012c23.2119 -0.371094 56.5723 31.4541 73.4131 44.7012c76.4189 59.8389 112.165 88.2305 134.587 106.49v40.8047zM48 48h416v185.601c-22.915 -18.252 -55.4189 -43.8691 -104.947 -82.6523
|
||||
c-22.5439 -17.748 -60.3359 -55.1787 -103.053 -54.9473c-42.9277 -0.231445 -81.2051 37.75 -103.062 54.9551c-49.5293 38.7842 -82.0244 64.3945 -104.938 82.6455v-185.602z" />
|
||||
<glyph glyph-name="lightbulb" unicode="" horiz-adv-x="352"
|
||||
d="M176 368c8.83984 0 16 -7.16016 16 -16s-7.16016 -16 -16 -16c-35.2803 0 -64 -28.7002 -64 -64c0 -8.83984 -7.16016 -16 -16 -16s-16 7.16016 -16 16c0 52.9404 43.0596 96 96 96zM96.0596 -11.1699l-0.0400391 43.1797h159.961l-0.0507812 -43.1797
|
||||
c-0.00976562 -3.13965 -0.939453 -6.21973 -2.67969 -8.83984l-24.5098 -36.8398c-2.95996 -4.45996 -7.95996 -7.14062 -13.3203 -7.14062h-78.8496c-5.35059 0 -10.3506 2.68066 -13.3203 7.14062l-24.5098 36.8398c-1.75 2.62012 -2.68066 5.68945 -2.68066 8.83984z
|
||||
M176 448c97.2002 0 176 -78.7998 176 -176c0 -44.3701 -16.4502 -84.8496 -43.5498 -115.79c-16.6406 -18.9795 -42.7402 -58.79 -52.4199 -92.1602v-0.0498047h-48v0.0996094c0.00488281 4.98145 0.790039 9.78809 2.21973 14.3008
|
||||
c5.67969 17.9893 22.9902 64.8496 62.0996 109.46c20.4102 23.29 31.6504 53.1699 31.6504 84.1396c0 70.5801 -57.4199 128 -128 128c-68.2803 0 -128.15 -54.3604 -127.95 -128c0.0898438 -30.9902 11.0703 -60.71 31.6104 -84.1396
|
||||
c39.3496 -44.9004 56.5801 -91.8604 62.1699 -109.67c1.42969 -4.56055 2.13965 -9.30078 2.15039 -14.0703v-0.120117h-48v0.0595703c-9.68066 33.3604 -35.7803 73.1709 -52.4209 92.1602c-27.1094 30.9307 -43.5596 71.4102 -43.5596 115.78
|
||||
c0 93.0303 73.7197 176 176 176z" />
|
||||
<glyph glyph-name="bell" unicode="" horiz-adv-x="448"
|
||||
d="M439.39 85.71c6 -6.44043 8.66016 -14.1602 8.61035 -21.71c-0.0996094 -16.4004 -12.9805 -32 -32.0996 -32h-383.801c-19.1191 0 -31.9893 15.5996 -32.0996 32c-0.0498047 7.5498 2.61035 15.2598 8.61035 21.71c19.3193 20.7598 55.4697 51.9902 55.4697 154.29
|
||||
c0 77.7002 54.4795 139.9 127.939 155.16v20.8398c0 17.6699 14.3203 32 31.9805 32s31.9805 -14.3301 31.9805 -32v-20.8398c73.46 -15.2598 127.939 -77.46 127.939 -155.16c0 -102.3 36.1504 -133.53 55.4697 -154.29zM67.5303 80h312.939
|
||||
c-21.2197 27.96 -44.4199 74.3203 -44.5293 159.42c0 0.200195 0.0595703 0.379883 0.0595703 0.580078c0 61.8604 -50.1396 112 -112 112s-112 -50.1396 -112 -112c0 -0.200195 0.0595703 -0.379883 0.0595703 -0.580078
|
||||
c-0.109375 -85.0898 -23.3096 -131.45 -44.5293 -159.42zM224 -64c-35.3203 0 -63.9697 28.6504 -63.9697 64h127.939c0 -35.3496 -28.6494 -64 -63.9697 -64z" />
|
||||
<glyph glyph-name="hospital" unicode="" horiz-adv-x="448"
|
||||
d="M128 204v40c0 6.62695 5.37305 12 12 12h40c6.62695 0 12 -5.37305 12 -12v-40c0 -6.62695 -5.37305 -12 -12 -12h-40c-6.62695 0 -12 5.37305 -12 12zM268 192c-6.62695 0 -12 5.37305 -12 12v40c0 6.62695 5.37305 12 12 12h40c6.62695 0 12 -5.37305 12 -12v-40
|
||||
c0 -6.62695 -5.37305 -12 -12 -12h-40zM192 108c0 -6.62695 -5.37305 -12 -12 -12h-40c-6.62695 0 -12 5.37305 -12 12v40c0 6.62695 5.37305 12 12 12h40c6.62695 0 12 -5.37305 12 -12v-40zM268 96c-6.62695 0 -12 5.37305 -12 12v40c0 6.62695 5.37305 12 12 12h40
|
||||
c6.62695 0 12 -5.37305 12 -12v-40c0 -6.62695 -5.37305 -12 -12 -12h-40zM448 -28v-36h-448v36c0 6.62695 5.37305 12 12 12h19.5v378.965c0 11.6172 10.7451 21.0352 24 21.0352h88.5v40c0 13.2549 10.7451 24 24 24h112c13.2549 0 24 -10.7451 24 -24v-40h88.5
|
||||
c13.2549 0 24 -9.41797 24 -21.0352v-378.965h19.5c6.62695 0 12 -5.37305 12 -12zM79.5 -15h112.5v67c0 6.62695 5.37305 12 12 12h40c6.62695 0 12 -5.37305 12 -12v-67h112.5v351h-64.5v-24c0 -13.2549 -10.7451 -24 -24 -24h-112c-13.2549 0 -24 10.7451 -24 24v24
|
||||
h-64.5v-351zM266 384h-26v26c0 3.31152 -2.68848 6 -6 6h-20c-3.31152 0 -6 -2.68848 -6 -6v-26h-26c-3.31152 0 -6 -2.68848 -6 -6v-20c0 -3.31152 2.68848 -6 6 -6h26v-26c0 -3.31152 2.68848 -6 6 -6h20c3.31152 0 6 2.68848 6 6v26h26c3.31152 0 6 2.68848 6 6v20
|
||||
c0 3.31152 -2.68848 6 -6 6z" />
|
||||
<glyph glyph-name="plus-square" unicode="" horiz-adv-x="448"
|
||||
d="M352 208v-32c0 -6.59961 -5.40039 -12 -12 -12h-88v-88c0 -6.59961 -5.40039 -12 -12 -12h-32c-6.59961 0 -12 5.40039 -12 12v88h-88c-6.59961 0 -12 5.40039 -12 12v32c0 6.59961 5.40039 12 12 12h88v88c0 6.59961 5.40039 12 12 12h32c6.59961 0 12 -5.40039 12 -12
|
||||
v-88h88c6.59961 0 12 -5.40039 12 -12zM448 368v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h352c26.5 0 48 -21.5 48 -48zM400 22v340c0 3.2998 -2.7002 6 -6 6h-340c-3.2998 0 -6 -2.7002 -6 -6v-340
|
||||
c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="circle" unicode=""
|
||||
d="M256 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM256 -8c110.5 0 200 89.5 200 200s-89.5 200 -200 200s-200 -89.5 -200 -200s89.5 -200 200 -200z" />
|
||||
<glyph glyph-name="smile" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM168 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32
|
||||
s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM328 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM332 135.4c8.5 10.1992 23.7002 11.5 33.7998 3.09961c10.2002 -8.5 11.6006 -23.5996 3.10059 -33.7998
|
||||
c-30 -36 -74.1006 -56.6006 -120.9 -56.6006s-90.9004 20.6006 -120.9 56.6006c-8.39941 10.2002 -7.09961 25.2998 3.10059 33.7998c10.0996 8.40039 25.2998 7.09961 33.7998 -3.09961c20.7998 -25.1006 51.5 -39.4004 84 -39.4004s63.2002 14.4004 84 39.4004z" />
|
||||
<glyph glyph-name="frown" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM168 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32
|
||||
s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM328 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32zM248 144c40.2002 0 78 -17.7002 103.8 -48.5996c8.40039 -10.2002 7.10059 -25.3008 -3.09961 -33.8008
|
||||
c-10.7002 -8.7998 -25.7002 -6.59961 -33.7998 3.10059c-16.6006 20 -41 31.3994 -66.9004 31.3994s-50.2998 -11.5 -66.9004 -31.3994c-8.5 -10.2002 -23.5996 -11.5 -33.7998 -3.10059c-10.2002 8.5 -11.5996 23.6006 -3.09961 33.8008
|
||||
c25.7998 30.8994 63.5996 48.5996 103.8 48.5996z" />
|
||||
<glyph glyph-name="meh" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM168 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32
|
||||
s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM328 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32zM336 128c13.2002 0 24 -10.7998 24 -24s-10.7998 -24 -24 -24h-176c-13.2002 0 -24 10.7998 -24 24s10.7998 24 24 24h176z
|
||||
" />
|
||||
<glyph glyph-name="keyboard" unicode="" horiz-adv-x="576"
|
||||
d="M528 384c26.5098 0 48 -21.4902 48 -48v-288c0 -26.5098 -21.4902 -48 -48 -48h-480c-26.5098 0 -48 21.4902 -48 48v288c0 26.5098 21.4902 48 48 48h480zM536 48v288c0 4.41113 -3.58887 8 -8 8h-480c-4.41113 0 -8 -3.58887 -8 -8v-288c0 -4.41113 3.58887 -8 8 -8
|
||||
h480c4.41113 0 8 3.58887 8 8zM170 178c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM266 178c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28
|
||||
c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM362 178c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM458 178c0 -6.62695 -5.37305 -12 -12 -12h-28
|
||||
c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM122 96c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM506 96
|
||||
c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM122 260c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28
|
||||
c6.62695 0 12 -5.37305 12 -12v-28zM218 260c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM314 260c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28
|
||||
c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM410 260c0 -6.62695 -5.37305 -12 -12 -12h-28c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM506 260c0 -6.62695 -5.37305 -12 -12 -12h-28
|
||||
c-6.62695 0 -12 5.37305 -12 12v28c0 6.62695 5.37305 12 12 12h28c6.62695 0 12 -5.37305 12 -12v-28zM408 102c0 -6.62695 -5.37305 -12 -12 -12h-216c-6.62695 0 -12 5.37305 -12 12v16c0 6.62695 5.37305 12 12 12h216c6.62695 0 12 -5.37305 12 -12v-16z" />
|
||||
<glyph glyph-name="calendar" unicode="" horiz-adv-x="448"
|
||||
d="M400 384c26.5 0 48 -21.5 48 -48v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h48v52c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-52h128v52c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12
|
||||
v-52h48zM394 -16c3.2998 0 6 2.7002 6 6v298h-352v-298c0 -3.2998 2.7002 -6 6 -6h340z" />
|
||||
<glyph glyph-name="play-circle" unicode=""
|
||||
d="M371.7 210c16.3994 -9.2002 16.3994 -32.9004 0 -42l-176 -101c-15.9004 -8.7998 -35.7002 2.59961 -35.7002 21v208c0 18.5 19.9004 29.7998 35.7002 21zM504 192c0 -137 -111 -248 -248 -248s-248 111 -248 248s111 248 248 248s248 -111 248 -248zM56 192
|
||||
c0 -110.5 89.5 -200 200 -200s200 89.5 200 200s-89.5 200 -200 200s-200 -89.5 -200 -200z" />
|
||||
<glyph glyph-name="minus-square" unicode="" horiz-adv-x="448"
|
||||
d="M108 164c-6.59961 0 -12 5.40039 -12 12v32c0 6.59961 5.40039 12 12 12h232c6.59961 0 12 -5.40039 12 -12v-32c0 -6.59961 -5.40039 -12 -12 -12h-232zM448 368v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h352
|
||||
c26.5 0 48 -21.5 48 -48zM400 22v340c0 3.2998 -2.7002 6 -6 6h-340c-3.2998 0 -6 -2.7002 -6 -6v-340c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="check-square" unicode="" horiz-adv-x="448"
|
||||
d="M400 416c26.5098 0 48 -21.4902 48 -48v-352c0 -26.5098 -21.4902 -48 -48 -48h-352c-26.5098 0 -48 21.4902 -48 48v352c0 26.5098 21.4902 48 48 48h352zM400 16v352h-352v-352h352zM364.136 257.724l-172.589 -171.204
|
||||
c-4.70508 -4.66699 -12.3027 -4.63672 -16.9697 0.0683594l-90.7812 91.5156c-4.66699 4.70508 -4.63672 12.3037 0.0693359 16.9717l22.7188 22.5361c4.70508 4.66699 12.3027 4.63672 16.9697 -0.0693359l59.792 -60.2773l141.353 140.217
|
||||
c4.70508 4.66699 12.3027 4.63672 16.9697 -0.0683594l22.5361 -22.7178c4.66699 -4.70605 4.63672 -12.3047 -0.0683594 -16.9717z" />
|
||||
<glyph glyph-name="share-square" unicode="" horiz-adv-x="576"
|
||||
d="M561.938 289.94c18.75 -18.7402 18.75 -49.1406 0 -67.8809l-143.998 -144c-29.9727 -29.9727 -81.9404 -9.05273 -81.9404 33.9404v53.7998c-101.266 -7.83691 -99.625 -31.6406 -84.1104 -78.7598c14.2285 -43.0889 -33.4736 -79.248 -71.0195 -55.7402
|
||||
c-51.6924 32.3057 -84.8701 83.0635 -84.8701 144.76c0 39.3408 12.2197 72.7402 36.3301 99.3008c19.8398 21.8398 47.7402 38.4697 82.9102 49.4199c36.7295 11.4395 78.3096 16.1094 120.76 17.9893v57.1982c0 42.9355 51.9258 63.9541 81.9404 33.9404zM384 112l144 144
|
||||
l-144 144v-104.09c-110.86 -0.90332 -240 -10.5166 -240 -119.851c0 -52.1396 32.79 -85.6094 62.3096 -104.06c-39.8174 120.65 48.999 141.918 177.69 143.84v-103.84zM408.74 27.5068c7.4375 2.125 14.5508 5.30566 20.9736 9.30273
|
||||
c7.97656 4.95215 18.2861 -0.825195 18.2861 -10.2139v-42.5957c0 -26.5098 -21.4902 -48 -48 -48h-352c-26.5098 0 -48 21.4902 -48 48v352c0 26.5098 21.4902 48 48 48h132c6.62695 0 12 -5.37305 12 -12v-4.48633c0 -4.91699 -2.9873 -9.36914 -7.56934 -11.1514
|
||||
c-13.7021 -5.33105 -26.3955 -11.5371 -38.0498 -18.585c-1.82715 -1.11523 -3.98633 -1.76953 -6.28027 -1.77734h-86.1006c-3.31152 0 -6 -2.68848 -6 -6v-340c0 -3.31152 2.68848 -6 6 -6h340c3.31152 0 6 2.68848 6 6v25.9658c0 5.37012 3.5791 10.0596 8.74023 11.541z
|
||||
" />
|
||||
<glyph glyph-name="compass" unicode="" horiz-adv-x="496"
|
||||
d="M347.94 318.14c16.6592 7.61035 33.8096 -9.54004 26.1992 -26.1992l-65.9697 -144.341c-3.19238 -6.9834 -8.78613 -12.5771 -15.7695 -15.7695l-144.341 -65.9697c-16.6592 -7.61035 -33.8096 9.5498 -26.1992 26.1992l65.9697 144.341
|
||||
c3.19238 6.9834 8.78613 12.5771 15.7695 15.7695zM270.58 169.42c12.4697 12.4697 12.4697 32.6904 0 45.1602s-32.6904 12.4697 -45.1602 0s-12.4697 -32.6904 0 -45.1602s32.6904 -12.4697 45.1602 0zM248 440c136.97 0 248 -111.03 248 -248s-111.03 -248 -248 -248
|
||||
s-248 111.03 -248 248s111.03 248 248 248zM248 -8c110.28 0 200 89.7197 200 200s-89.7197 200 -200 200s-200 -89.7197 -200 -200s89.7197 -200 200 -200z" />
|
||||
<glyph glyph-name="caret-square-down" unicode="" horiz-adv-x="448"
|
||||
d="M125.1 240h197.801c10.6992 0 16.0996 -13 8.5 -20.5l-98.9004 -98.2998c-4.7002 -4.7002 -12.2002 -4.7002 -16.9004 0l-98.8994 98.2998c-7.7002 7.5 -2.2998 20.5 8.39941 20.5zM448 368v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352
|
||||
c0 26.5 21.5 48 48 48h352c26.5 0 48 -21.5 48 -48zM400 22v340c0 3.2998 -2.7002 6 -6 6h-340c-3.2998 0 -6 -2.7002 -6 -6v-340c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="caret-square-up" unicode="" horiz-adv-x="448"
|
||||
d="M322.9 144h-197.801c-10.6992 0 -16.0996 13 -8.5 20.5l98.9004 98.2998c4.7002 4.7002 12.2002 4.7002 16.9004 0l98.8994 -98.2998c7.7002 -7.5 2.2998 -20.5 -8.39941 -20.5zM448 368v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352
|
||||
c0 26.5 21.5 48 48 48h352c26.5 0 48 -21.5 48 -48zM400 22v340c0 3.2998 -2.7002 6 -6 6h-340c-3.2998 0 -6 -2.7002 -6 -6v-340c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="caret-square-right" unicode="" horiz-adv-x="448"
|
||||
d="M176 93.0996v197.801c0 10.6992 13 16.0996 20.5 8.5l98.2998 -98.9004c4.7002 -4.7002 4.7002 -12.2002 0 -16.9004l-98.2998 -98.8994c-7.5 -7.7002 -20.5 -2.2998 -20.5 8.39941zM448 368v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352
|
||||
c0 26.5 21.5 48 48 48h352c26.5 0 48 -21.5 48 -48zM400 22v340c0 3.2998 -2.7002 6 -6 6h-340c-3.2998 0 -6 -2.7002 -6 -6v-340c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="file" unicode="" horiz-adv-x="384"
|
||||
d="M369.9 350.1c9 -9 14.0996 -21.2998 14.0996 -34v-332.1c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48.0996h204.1c12.7002 0 24.9004 -5.09961 33.9004 -14.0996zM332.1 320l-76.0996 76.0996v-76.0996h76.0996zM48 -16h288v288
|
||||
h-104c-13.2998 0 -24 10.7002 -24 24v104h-160v-416z" />
|
||||
<glyph glyph-name="file-alt" unicode="" horiz-adv-x="384"
|
||||
d="M288 200v-28c0 -6.59961 -5.40039 -12 -12 -12h-168c-6.59961 0 -12 5.40039 -12 12v28c0 6.59961 5.40039 12 12 12h168c6.59961 0 12 -5.40039 12 -12zM276 128c6.59961 0 12 -5.40039 12 -12v-28c0 -6.59961 -5.40039 -12 -12 -12h-168c-6.59961 0 -12 5.40039 -12 12
|
||||
v28c0 6.59961 5.40039 12 12 12h168zM384 316.1v-332.1c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48h204.1c12.7002 0 24.9004 -5.09961 33.9004 -14.0996l83.9004 -83.9004c9 -8.90039 14.0996 -21.2002 14.0996 -33.9004z
|
||||
M256 396.1v-76.0996h76.0996zM336 -16v288h-104c-13.2998 0 -24 10.7002 -24 24v104h-160v-416h288z" />
|
||||
<glyph glyph-name="thumbs-up" unicode=""
|
||||
d="M466.27 161.31c4.6748 -22.6465 0.864258 -44.5371 -8.98926 -62.9893c2.95898 -23.8682 -4.02148 -48.5654 -17.3398 -66.9902c-0.954102 -55.9072 -35.8232 -95.3301 -112.94 -95.3301c-7 0 -15 0.00976562 -22.2197 0.00976562
|
||||
c-102.742 0 -133.293 38.9395 -177.803 39.9404c-3.56934 -13.7764 -16.085 -23.9502 -30.9775 -23.9502h-64c-17.6729 0 -32 14.3271 -32 32v240c0 17.6729 14.3271 32 32 32h98.7598c19.1455 16.9531 46.0137 60.6533 68.7598 83.4004
|
||||
c13.667 13.667 10.1533 108.6 71.7607 108.6c57.5801 0 95.2695 -31.9355 95.2695 -104.73c0 -18.4092 -3.92969 -33.7295 -8.84961 -46.5391h36.4795c48.6025 0 85.8203 -41.5654 85.8203 -85.5801c0 -19.1504 -4.95996 -34.9902 -13.7305 -49.8408zM404.52 107.48
|
||||
c21.5811 20.3838 18.6992 51.0645 5.21094 65.6191c9.44922 0 22.3594 18.9102 22.2695 37.8105c-0.0898438 18.9102 -16.71 37.8203 -37.8203 37.8203h-103.989c0 37.8193 28.3594 55.3691 28.3594 94.5391c0 23.75 0 56.7305 -47.2695 56.7305
|
||||
c-18.9102 -18.9102 -9.45996 -66.1797 -37.8203 -94.54c-26.5596 -26.5703 -66.1797 -97.46 -94.54 -97.46h-10.9199v-186.17c53.6113 0 100.001 -37.8203 171.64 -37.8203h37.8203c35.5117 0 60.8203 17.1201 53.1201 65.9004
|
||||
c15.2002 8.16016 26.5 36.4395 13.9395 57.5703zM88 16c0 13.2549 -10.7451 24 -24 24s-24 -10.7451 -24 -24s10.7451 -24 24 -24s24 10.7451 24 24z" />
|
||||
<glyph glyph-name="thumbs-down" unicode=""
|
||||
d="M466.27 222.69c8.77051 -14.8506 13.7305 -30.6904 13.7305 -49.8408c0 -44.0146 -37.2178 -85.5801 -85.8203 -85.5801h-36.4795c4.91992 -12.8096 8.84961 -28.1299 8.84961 -46.5391c0 -72.7949 -37.6895 -104.73 -95.2695 -104.73
|
||||
c-61.6074 0 -58.0938 94.9326 -71.7607 108.6c-22.7461 22.7471 -49.6133 66.4473 -68.7598 83.4004h-7.05176c-5.5332 -9.56152 -15.8662 -16 -27.708 -16h-64c-17.6729 0 -32 14.3271 -32 32v240c0 17.6729 14.3271 32 32 32h64c8.11328 0 15.5146 -3.02539 21.1553 -8
|
||||
h10.8447c40.9971 0 73.1953 39.9902 176.78 39.9902c7.21973 0 15.2197 0.00976562 22.2197 0.00976562c77.1172 0 111.986 -39.4229 112.94 -95.3301c13.3184 -18.4248 20.2979 -43.1221 17.3398 -66.9902c9.85352 -18.4521 13.6641 -40.3428 8.98926 -62.9893zM64 152
|
||||
c13.2549 0 24 10.7451 24 24s-10.7451 24 -24 24s-24 -10.7451 -24 -24s10.7451 -24 24 -24zM394.18 135.27c21.1104 0 37.7305 18.9102 37.8203 37.8203c0.0898438 18.9004 -12.8203 37.8105 -22.2695 37.8105c13.4883 14.5547 16.3701 45.2354 -5.21094 65.6191
|
||||
c12.5605 21.1309 1.26074 49.4102 -13.9395 57.5703c7.7002 48.7803 -17.6084 65.9004 -53.1201 65.9004h-37.8203c-71.6387 0 -118.028 -37.8203 -171.64 -37.8203v-186.17h10.9199c28.3604 0 67.9805 -70.8896 94.54 -97.46
|
||||
c28.3604 -28.3604 18.9102 -75.6299 37.8203 -94.54c47.2695 0 47.2695 32.9805 47.2695 56.7305c0 39.1699 -28.3594 56.7197 -28.3594 94.5391h103.989z" />
|
||||
<glyph glyph-name="sun" unicode=""
|
||||
d="M494.2 226.1c11.2002 -7.59961 17.7998 -20.0996 17.8994 -33.6992c0 -13.4004 -6.69922 -26 -17.7998 -33.5l-59.7998 -40.5l13.7002 -71c2.5 -13.2002 -1.60059 -26.8008 -11.1006 -36.3008s-22.8994 -13.7998 -36.2998 -11.0996l-70.8994 13.7002l-40.4004 -59.9004
|
||||
c-7.5 -11.0996 -20.0996 -17.7998 -33.5 -17.7998s-26 6.7002 -33.5 17.9004l-40.4004 59.8994l-70.7998 -13.7002c-13.3994 -2.59961 -26.7998 1.60059 -36.2998 11.1006s-13.7002 23.0996 -11.0996 36.2998l13.6992 71l-59.7998 40.5
|
||||
c-11.0996 7.5 -17.7998 20 -17.7998 33.5s6.59961 26 17.7998 33.5996l59.7998 40.5l-13.6992 71c-2.60059 13.2002 1.59961 26.7002 11.0996 36.3008c9.5 9.59961 23 13.6992 36.2998 11.1992l70.7998 -13.6992l40.4004 59.8994c15.0996 22.2998 51.9004 22.2998 67 0
|
||||
l40.4004 -59.8994l70.8994 13.6992c13 2.60059 26.6006 -1.59961 36.2002 -11.0996c9.5 -9.59961 13.7002 -23.2002 11.0996 -36.4004l-13.6992 -71zM381.3 140.5l76.7998 52.0996l-76.7998 52l17.6006 91.1006l-91 -17.6006l-51.9004 76.9004l-51.7998 -76.7998
|
||||
l-91 17.5996l17.5996 -91.2002l-76.7998 -52l76.7998 -52l-17.5996 -91.1992l90.8994 17.5996l51.9004 -77l51.9004 76.9004l91 -17.6006zM256 296c57.2998 0 104 -46.7002 104 -104s-46.7002 -104 -104 -104s-104 46.7002 -104 104s46.7002 104 104 104zM256 136
|
||||
c30.9004 0 56 25.0996 56 56s-25.0996 56 -56 56s-56 -25.0996 -56 -56s25.0996 -56 56 -56z" />
|
||||
<glyph glyph-name="moon" unicode=""
|
||||
d="M279.135 -64c-141.424 0 -256 114.64 -256 256c0 141.425 114.641 256 256 256c16.0342 -0.00292969 31.5078 -1.46875 46.7354 -4.27734c44.0205 -8.13086 53.7666 -66.8691 15.0215 -88.9189c-41.374 -23.5439 -67.4336 -67.4121 -67.4336 -115.836
|
||||
c0 -83.5234 75.9238 -146.475 158.272 -130.792c43.6904 8.32129 74.5186 -42.5693 46.248 -77.4004c-47.8613 -58.9717 -120.088 -94.7754 -198.844 -94.7754zM279.135 400c-114.875 0 -208 -93.125 -208 -208s93.125 -208 208 -208
|
||||
c65.2314 0 123.439 30.0361 161.575 77.0244c-111.611 -21.2568 -215.252 64.0957 -215.252 177.943c0 67.5127 36.9326 126.392 91.6934 157.555c-12.3271 2.27637 -25.0312 3.47754 -38.0166 3.47754z" />
|
||||
<glyph glyph-name="caret-square-left" unicode="" horiz-adv-x="448"
|
||||
d="M272 290.9v-197.801c0 -10.6992 -13 -16.0996 -20.5 -8.5l-98.2998 98.9004c-4.7002 4.7002 -4.7002 12.2002 0 16.9004l98.2998 98.8994c7.5 7.7002 20.5 2.2998 20.5 -8.39941zM448 368v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352
|
||||
c0 26.5 21.5 48 48 48h352c26.5 0 48 -21.5 48 -48zM400 22v340c0 3.2998 -2.7002 6 -6 6h-340c-3.2998 0 -6 -2.7002 -6 -6v-340c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="dot-circle" unicode=""
|
||||
d="M256 392c-110.549 0 -200 -89.4678 -200 -200c0 -110.549 89.4678 -200 200 -200c110.549 0 200 89.4678 200 200c0 110.549 -89.4678 200 -200 200zM256 440c136.967 0 248 -111.033 248 -248s-111.033 -248 -248 -248s-248 111.033 -248 248s111.033 248 248 248z
|
||||
M256 272c44.1826 0 80 -35.8174 80 -80s-35.8174 -80 -80 -80s-80 35.8174 -80 80s35.8174 80 80 80z" />
|
||||
<glyph glyph-name="building" unicode="" horiz-adv-x="448"
|
||||
d="M128 300v40c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40c0 -6.59961 -5.40039 -12 -12 -12h-40c-6.59961 0 -12 5.40039 -12 12zM268 288c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40
|
||||
c0 -6.59961 -5.40039 -12 -12 -12h-40zM140 192c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40c0 -6.59961 -5.40039 -12 -12 -12h-40zM268 192c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40
|
||||
c6.59961 0 12 -5.40039 12 -12v-40c0 -6.59961 -5.40039 -12 -12 -12h-40zM192 108c0 -6.59961 -5.40039 -12 -12 -12h-40c-6.59961 0 -12 5.40039 -12 12v40c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40zM268 96c-6.59961 0 -12 5.40039 -12 12v40
|
||||
c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-40c0 -6.59961 -5.40039 -12 -12 -12h-40zM448 -28v-36h-448v36c0 6.59961 5.40039 12 12 12h19.5v440c0 13.2998 10.7002 24 24 24h337c13.2998 0 24 -10.7002 24 -24v-440h19.5
|
||||
c6.59961 0 12 -5.40039 12 -12zM79.5 -15h112.5v67c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-67h112.5v414l-288.5 1z" />
|
||||
<glyph glyph-name="file-pdf" unicode="" horiz-adv-x="384"
|
||||
d="M369.9 350.1c9 -9 14.0996 -21.2998 14.0996 -34v-332.1c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48.0996h204.1c12.7002 0 24.9004 -5.09961 33.9004 -14.0996zM332.1 320l-76.0996 76.0996v-76.0996h76.0996zM48 -16h288v288
|
||||
h-104c-13.2998 0 -24 10.7002 -24 24v104h-160v-416zM298.2 127.7c10.5 -10.5 8 -38.7002 -17.5 -38.7002c-14.7998 0 -36.9004 6.7998 -55.7998 17c-21.6006 -3.59961 -46 -12.7002 -68.4004 -20.0996c-50.0996 -86.4004 -79.4004 -47 -76.0996 -31.2002
|
||||
c4 20 31 35.8994 51 46.2002c10.5 18.3994 25.3994 50.5 35.3994 74.3994c-7.39941 28.6006 -11.3994 51 -7 67.1006c4.7998 17.6992 38.4004 20.2998 42.6006 -5.90039c4.69922 -15.4004 -1.5 -39.9004 -5.40039 -56c8.09961 -21.2998 19.5996 -35.7998 36.7998 -46.2998
|
||||
c17.4004 2.2002 52.2002 5.5 64.4004 -6.5zM100.1 49.9004c0 -0.700195 11.4004 4.69922 30.4004 35c-5.90039 -5.5 -25.2998 -21.3008 -30.4004 -35zM181.7 240.5c-2.5 0 -2.60059 -26.9004 1.7998 -40.7998c4.90039 8.7002 5.59961 40.7998 -1.7998 40.7998zM157.3 103.9
|
||||
c15.9004 6.09961 34 14.8994 54.7998 19.1992c-11.1992 8.30078 -21.7998 20.4004 -30.0996 35.5c-6.7002 -17.6992 -15 -37.7998 -24.7002 -54.6992zM288.9 108.9c3.59961 2.39941 -2.2002 10.3994 -37.3008 7.7998c32.3008 -13.7998 37.3008 -7.7998 37.3008 -7.7998z" />
|
||||
<glyph glyph-name="file-word" unicode="" horiz-adv-x="384"
|
||||
d="M369.9 350.1c9 -9 14.0996 -21.2998 14.0996 -34v-332.1c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48.0996h204.1c12.7002 0 24.9004 -5.09961 33.9004 -14.0996zM332.1 320l-76.0996 76.0996v-76.0996h76.0996zM48 -16h288v288
|
||||
h-104c-13.2998 0 -24 10.7002 -24 24v104h-160v-416zM268.1 192v0.200195h15.8008c7.7998 0 13.5 -7.2998 11.5996 -14.9004c-4.2998 -17 -13.7002 -54.0996 -34.5 -136c-1.2998 -5.39941 -6.09961 -9.09961 -11.5996 -9.09961h-24.7002
|
||||
c-5.5 0 -10.2998 3.7998 -11.6006 9.09961c-5.2998 20.9004 -17.7998 71 -17.8994 71.4004l-2.90039 17.2998c-0.5 -5.2998 -1.5 -11.0996 -3 -17.2998l-17.8994 -71.4004c-1.30078 -5.39941 -6.10059 -9.09961 -11.6006 -9.09961h-25.2002
|
||||
c-5.59961 0 -10.3994 3.7002 -11.6992 9.09961c-6.5 26.5 -25.2002 103.4 -33.2002 136c-1.7998 7.5 3.89941 14.7998 11.7002 14.7998h16.7998c5.7998 0 10.7002 -4.09961 11.7998 -9.69922c5 -25.7002 18.4004 -93.8008 19.0996 -99
|
||||
c0.300781 -1.7002 0.400391 -3.10059 0.5 -4.2002c0.800781 7.5 0.400391 4.7002 24.8008 103.7c1.39941 5.2998 6.19922 9.09961 11.6992 9.09961h13.3008c5.59961 0 10.3994 -3.7998 11.6992 -9.2002c23.9004 -99.7002 22.8008 -94.3994 23.6006 -99.5
|
||||
c0.299805 -1.7002 0.5 -3.09961 0.700195 -4.2998c0.599609 8.09961 0.399414 5.7998 21 103.5c1.09961 5.5 6 9.5 11.6992 9.5z" />
|
||||
<glyph glyph-name="file-excel" unicode="" horiz-adv-x="384"
|
||||
d="M369.9 350.1c9 -9 14.0996 -21.2998 14.0996 -34v-332.1c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48.0996h204.1c12.7002 0 24.9004 -5.09961 33.9004 -14.0996zM332.1 320l-76.0996 76.0996v-76.0996h76.0996zM48 -16h288v288
|
||||
h-104c-13.2998 0 -24 10.7002 -24 24v104h-160v-416zM260 224c9.2002 0 15 -10 10.2998 -18c-16 -27.5 -45.5996 -76.9004 -46.2998 -78l46.4004 -78c4.59961 -8 -1.10059 -18 -10.4004 -18h-28.7998c-4.40039 0 -8.5 2.40039 -10.6006 6.2998
|
||||
c-22.6992 41.7998 -13.6992 27.5 -28.5996 57.7002c-5.59961 -12.7002 -6.90039 -17.7002 -28.5996 -57.7002c-2.10059 -3.89941 -6.10059 -6.2998 -10.5 -6.2998h-28.9004c-9.2998 0 -15.0996 10 -10.4004 18l46.3008 78l-46.3008 78c-4.59961 8 1.10059 18 10.4004 18
|
||||
h28.9004c4.39941 0 8.5 -2.40039 10.5996 -6.2998c21.7002 -40.4004 14.7002 -28.6006 28.5996 -57.7002c6.40039 15.2998 10.6006 24.5996 28.6006 57.7002c2.09961 3.89941 6.09961 6.2998 10.5 6.2998h28.7998z" />
|
||||
<glyph glyph-name="file-powerpoint" unicode="" horiz-adv-x="384"
|
||||
d="M369.9 350.1c9 -9 14.0996 -21.2998 14.0996 -34v-332.1c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48.0996h204.1c12.7002 0 24.9004 -5.09961 33.9004 -14.0996zM332.1 320l-76.0996 76.0996v-76.0996h76.0996zM48 -16h288v288
|
||||
h-104c-13.2998 0 -24 10.7002 -24 24v104h-160v-416zM120 44v168c0 6.59961 5.40039 12 12 12h69.2002c36.7002 0 62.7998 -27 62.7998 -66.2998c0 -74.2998 -68.7002 -66.5 -95.5 -66.5v-47.2002c0 -6.59961 -5.40039 -12 -12 -12h-24.5c-6.59961 0 -12 5.40039 -12 12z
|
||||
M168.5 131.4h23c7.90039 0 13.9004 2.39941 18.0996 7.19922c8.5 9.80078 8.40039 28.5 0.100586 37.8008c-4.10059 4.59961 -9.90039 7 -17.4004 7h-23.8994v-52h0.0996094z" />
|
||||
<glyph glyph-name="file-image" unicode="" horiz-adv-x="384"
|
||||
d="M369.9 350.1c9 -9 14.0996 -21.2998 14.0996 -34v-332.1c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48.0996h204.1c12.7002 0 24.9004 -5.09961 33.9004 -14.0996zM332.1 320l-76.0996 76.0996v-76.0996h76.0996zM48 -16h288v288
|
||||
h-104c-13.2998 0 -24 10.7002 -24 24v104h-160v-416zM80 32v64l39.5 39.5c4.7002 4.7002 12.2998 4.7002 17 0l39.5 -39.5l87.5 87.5c4.7002 4.7002 12.2998 4.7002 17 0l23.5 -23.5v-128h-224zM128 272c26.5 0 48 -21.5 48 -48s-21.5 -48 -48 -48s-48 21.5 -48 48
|
||||
s21.5 48 48 48z" />
|
||||
<glyph glyph-name="file-archive" unicode="" horiz-adv-x="384"
|
||||
d="M128.3 288h32v-32h-32v32zM192.3 384v-32h-32v32h32zM128.3 352h32v-32h-32v32zM192.3 320v-32h-32v32h32zM369.9 350.1c9 -9 14.0996 -21.2998 14.0996 -34v-332.1c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48.0996h204.1
|
||||
c12.7002 0 24.9004 -5.09961 33.9004 -14.0996zM256 396.1v-76.0996h76.0996zM336 -16v288h-104c-13.2998 0 -24 10.7002 -24 24v104h-48.2998v-16h-32v16h-79.7002v-416h288zM194.2 182.3l17.2998 -87.7002c6.40039 -32.3994 -18.4004 -62.5996 -51.5 -62.5996
|
||||
c-33.2002 0 -58 30.4004 -51.4004 62.9004l19.7002 97.0996v32h32v-32h22.1006c5.7998 0 10.6992 -4.09961 11.7998 -9.7002zM160.3 57.9004c17.9004 0 32.4004 12.0996 32.4004 27c0 14.8994 -14.5 27 -32.4004 27c-17.8994 0 -32.3994 -12.1006 -32.3994 -27
|
||||
c0 -14.9004 14.5 -27 32.3994 -27zM192.3 256v-32h-32v32h32z" />
|
||||
<glyph glyph-name="file-audio" unicode="" horiz-adv-x="384"
|
||||
d="M369.941 350.059c8.68848 -8.68848 14.0586 -20.6943 14.0586 -33.9404v-332.118c0 -26.5098 -21.4902 -48 -48 -48h-288c-26.5098 0 -48 21.4902 -48 48v416c0 26.5098 21.4902 48 48 48h204.118c13.2461 0 25.252 -5.37012 33.9404 -14.0586zM332.118 320
|
||||
l-76.1182 76.1182v-76.1182h76.1182zM48 -16h288v288h-104c-13.2549 0 -24 10.7451 -24 24v104h-160v-416zM192 60.0244c0 -10.6914 -12.9258 -16.0459 -20.4854 -8.48535l-35.5146 35.9746h-28c-6.62695 0 -12 5.37305 -12 12v56c0 6.62695 5.37305 12 12 12h28
|
||||
l35.5146 36.9473c7.56055 7.56055 20.4854 2.20605 20.4854 -8.48535v-135.951zM233.201 107.154c9.05078 9.29688 9.05957 24.1328 0.000976562 33.4385c-22.1494 22.752 12.2344 56.2461 34.3945 33.4814c27.1982 -27.9404 27.2119 -72.4443 0.000976562 -100.401
|
||||
c-21.793 -22.3857 -56.9463 10.3154 -34.3965 33.4814z" />
|
||||
<glyph glyph-name="file-video" unicode="" horiz-adv-x="384"
|
||||
d="M369.941 350.059c8.68848 -8.68848 14.0586 -20.6943 14.0586 -33.9404v-332.118c0 -26.5098 -21.4902 -48 -48 -48h-288c-26.5098 0 -48 21.4902 -48 48v416c0 26.5098 21.4902 48 48 48h204.118c13.2461 0 25.252 -5.37012 33.9404 -14.0586zM332.118 320
|
||||
l-76.1182 76.1182v-76.1182h76.1182zM48 -16h288v288h-104c-13.2549 0 -24 10.7451 -24 24v104h-160v-416zM276.687 195.303c10.0049 10.0049 27.3135 2.99707 27.3135 -11.3135v-111.976c0 -14.2939 -17.2959 -21.332 -27.3135 -11.3135l-52.6865 52.6738v-37.374
|
||||
c0 -11.0459 -8.9541 -20 -20 -20h-104c-11.0459 0 -20 8.9541 -20 20v104c0 11.0459 8.9541 20 20 20h104c11.0459 0 20 -8.9541 20 -20v-37.374z" />
|
||||
<glyph glyph-name="file-code" unicode="" horiz-adv-x="384"
|
||||
d="M149.9 98.9004c3.5 -3.30078 3.69922 -8.90039 0.399414 -12.4004l-17.3994 -18.5996c-1.60059 -1.80078 -4 -2.80078 -6.40039 -2.80078c-2.2002 0 -4.40039 0.900391 -6 2.40039l-57.7002 54.0996c-3.7002 3.40039 -3.7002 9.30078 0 12.8008l57.7002 54.0996
|
||||
c3.40039 3.2998 9 3.2002 12.4004 -0.400391l17.3994 -18.5996l0.200195 -0.200195c3.2002 -3.59961 2.7998 -9.2002 -0.799805 -12.3994l-32.7998 -28.9004l32.7998 -28.9004zM369.9 350.1c9 -9 14.0996 -21.2998 14.0996 -34v-332.1c0 -26.5 -21.5 -48 -48 -48h-288
|
||||
c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48.0996h204.1c12.7002 0 24.9004 -5.09961 33.9004 -14.0996zM256 396.1v-76.0996h76.0996zM336 -16v288h-104c-13.2998 0 -24 10.7002 -24 24v104h-160v-416h288zM209.6 234l24.4004 -7
|
||||
c4.7002 -1.2998 7.40039 -6.2002 6 -10.9004l-54.7002 -188.199c-1.2998 -4.60059 -6.2002 -7.40039 -10.8994 -6l-24.4004 7.09961c-4.7002 1.2998 -7.40039 6.2002 -6 10.9004l54.7002 188.1c1.39941 4.7002 6.2002 7.40039 10.8994 6zM234.1 157.1
|
||||
c-3.5 3.30078 -3.69922 8.90039 -0.399414 12.4004l17.3994 18.5996c3.30078 3.60059 8.90039 3.7002 12.4004 0.400391l57.7002 -54.0996c3.7002 -3.40039 3.7002 -9.30078 0 -12.8008l-57.7002 -54.0996c-3.5 -3.2998 -9.09961 -3.09961 -12.4004 0.400391
|
||||
l-17.3994 18.5996l-0.200195 0.200195c-3.2002 3.59961 -2.7998 9.2002 0.799805 12.3994l32.7998 28.9004l-32.7998 28.9004z" />
|
||||
<glyph glyph-name="life-ring" unicode=""
|
||||
d="M256 -56c-136.967 0 -248 111.033 -248 248s111.033 248 248 248s248 -111.033 248 -248s-111.033 -248 -248 -248zM152.602 20.7197c63.2178 -38.3184 143.579 -38.3184 206.797 0l-53.4111 53.4111c-31.8467 -13.5215 -68.168 -13.5059 -99.9746 0zM336 192
|
||||
c0 44.1123 -35.8877 80 -80 80s-80 -35.8877 -80 -80s35.8877 -80 80 -80s80 35.8877 80 80zM427.28 88.6016c38.3184 63.2178 38.3184 143.579 0 206.797l-53.4111 -53.4111c13.5215 -31.8467 13.5049 -68.168 0 -99.9746zM359.397 363.28
|
||||
c-63.2168 38.3184 -143.578 38.3184 -206.796 0l53.4111 -53.4111c31.8457 13.5215 68.167 13.5049 99.9736 0zM84.7197 295.398c-38.3184 -63.2178 -38.3184 -143.579 0 -206.797l53.4111 53.4111c-13.5215 31.8467 -13.5059 68.168 0 99.9746z" />
|
||||
<glyph glyph-name="paper-plane" unicode=""
|
||||
d="M440 441.5c34.5996 19.9004 77.5996 -8.7998 71.5 -48.9004l-59.4004 -387.199c-2.2998 -14.5 -11.0996 -27.3008 -23.8994 -34.5c-7.2998 -4.10059 -15.4004 -6.2002 -23.6006 -6.2002c-6.19922 0 -12.3994 1.2002 -18.2998 3.59961l-111.899 46.2002l-43.8008 -59.0996
|
||||
c-27.3994 -36.9004 -86.5996 -17.8008 -86.5996 28.5996v84.4004l-114.3 47.2998c-36.7998 15.0996 -40.1006 66 -5.7002 85.8994zM192 -16l36.5996 49.5l-36.5996 15.0996v-64.5996zM404.6 12.7002l59.4004 387.3l-416 -240l107.8 -44.5996l211.5 184.3
|
||||
c14.2002 12.2998 34.4004 -5.7002 23.7002 -21.2002l-140.2 -202.3z" />
|
||||
<glyph glyph-name="futbol" unicode="" horiz-adv-x="496"
|
||||
d="M483.8 268.6c42.2998 -130.199 -29 -270.1 -159.2 -312.399c-25.5 -8.2998 -51.2998 -12.2002 -76.6992 -12.2002c-104.5 0 -201.7 66.5996 -235.7 171.4c-42.2998 130.199 29 270.1 159.2 312.399c25.5 8.2998 51.2998 12.2002 76.6992 12.2002
|
||||
c104.5 0 201.7 -66.5996 235.7 -171.4zM409.3 74.9004c6.10059 8.39941 12.1006 16.8994 16.7998 26.1992c14.3008 28.1006 21.5 58.5 21.7002 89.2002l-38.8994 36.4004l-71.1006 -22.1006l-24.3994 -75.1992l43.6992 -60.9004zM409.3 310.3
|
||||
c-24.5 33.4004 -58.7002 58.4004 -97.8994 71.4004l-47.4004 -26.2002v-73.7998l64.2002 -46.5l70.7002 22zM184.9 381.6c-39.9004 -13.2998 -73.5 -38.5 -97.8008 -71.8994l10.1006 -52.5l70.5996 -22l64.2002 46.5v73.7998zM139 68.5l43.5 61.7002l-24.2998 74.2998
|
||||
l-71.1006 22.2002l-39 -36.4004c0.5 -55.7002 23.4004 -95.2002 37.8008 -115.3zM187.2 1.5c64.0996 -20.4004 115.5 -1.7998 121.7 0l22.3994 48.0996l-44.2998 61.7002h-78.5996l-43.6006 -61.7002z" />
|
||||
<glyph glyph-name="newspaper" unicode="" horiz-adv-x="576"
|
||||
d="M552 384c13.2549 0 24 -10.7451 24 -24v-336c0 -13.2549 -10.7451 -24 -24 -24h-496c-30.9277 0 -56 25.0723 -56 56v272c0 13.2549 10.7451 24 24 24h42.752c6.60547 18.623 24.3896 32 45.248 32h440zM48 56c0 -4.41113 3.58887 -8 8 -8s8 3.58887 8 8v248h-16v-248z
|
||||
M528 48v288h-416v-280c0 -2.7168 -0.204102 -5.38574 -0.578125 -8h416.578zM172 168c-6.62695 0 -12 5.37305 -12 12v96c0 6.62695 5.37305 12 12 12h136c6.62695 0 12 -5.37305 12 -12v-96c0 -6.62695 -5.37305 -12 -12 -12h-136zM200 248v-40h80v40h-80zM160 108v24
|
||||
c0 6.62695 5.37305 12 12 12h136c6.62695 0 12 -5.37305 12 -12v-24c0 -6.62695 -5.37305 -12 -12 -12h-136c-6.62695 0 -12 5.37305 -12 12zM352 108v24c0 6.62695 5.37305 12 12 12h104c6.62695 0 12 -5.37305 12 -12v-24c0 -6.62695 -5.37305 -12 -12 -12h-104
|
||||
c-6.62695 0 -12 5.37305 -12 12zM352 252v24c0 6.62695 5.37305 12 12 12h104c6.62695 0 12 -5.37305 12 -12v-24c0 -6.62695 -5.37305 -12 -12 -12h-104c-6.62695 0 -12 5.37305 -12 12zM352 180v24c0 6.62695 5.37305 12 12 12h104c6.62695 0 12 -5.37305 12 -12v-24
|
||||
c0 -6.62695 -5.37305 -12 -12 -12h-104c-6.62695 0 -12 5.37305 -12 12z" />
|
||||
<glyph glyph-name="bell-slash" unicode="" horiz-adv-x="640"
|
||||
d="M633.99 -23.0195c6.91016 -5.52051 8.01953 -15.5908 2.5 -22.4902l-10 -12.4902c-5.53027 -6.88965 -15.5898 -8.00977 -22.4902 -2.49023l-598 467.51c-6.90039 5.52051 -8.01953 15.5908 -2.49023 22.4902l10 12.4902
|
||||
c5.52051 6.90039 15.5898 8.00977 22.4902 2.49023zM163.53 80h182.84l61.3994 -48h-279.659c-19.1201 0 -31.9902 15.5996 -32.1006 32c-0.0498047 7.5498 2.61035 15.2598 8.61035 21.71c18.3701 19.7402 51.5703 49.6904 54.8398 140.42l45.4697 -35.5498
|
||||
c-6.91992 -54.7803 -24.6895 -88.5498 -41.3994 -110.58zM320 352c-23.3496 0 -45 -7.17969 -62.9404 -19.4004l-38.1699 29.8408c19.6807 15.7793 43.1104 27.3096 69.1299 32.7197v20.8398c0 17.6699 14.3203 32 31.9805 32s31.9805 -14.3301 31.9805 -32v-20.8398
|
||||
c73.46 -15.2598 127.939 -77.46 127.939 -155.16c0 -41.3604 6.03027 -70.7197 14.3398 -92.8496l-59.5293 46.54c-1.63086 13.96 -2.77051 28.8896 -2.79004 45.7295c0 0.200195 0.0595703 0.379883 0.0595703 0.580078c0 61.8604 -50.1396 112 -112 112zM320 -64
|
||||
c-35.3203 0 -63.9697 28.6504 -63.9697 64h127.939c0 -35.3496 -28.6494 -64 -63.9697 -64z" />
|
||||
<glyph glyph-name="copyright" unicode=""
|
||||
d="M256 440c136.967 0 248 -111.033 248 -248s-111.033 -248 -248 -248s-248 111.033 -248 248s111.033 248 248 248zM256 -8c110.549 0 200 89.4678 200 200c0 110.549 -89.4678 200 -200 200c-110.549 0 -200 -89.4688 -200 -200c0 -110.549 89.4678 -200 200 -200z
|
||||
M363.351 93.0645c-9.61328 -9.71289 -45.5293 -41.3965 -104.064 -41.3965c-82.4297 0 -140.484 61.4248 -140.484 141.567c0 79.1514 60.2754 139.4 139.763 139.4c55.5303 0 88.7373 -26.6201 97.5928 -34.7783c2.37793 -2.1875 3.86914 -5.3252 3.86914 -8.80762
|
||||
c0 -2.39746 -0.717773 -4.64258 -1.93359 -6.51465l-18.1543 -28.1133c-3.8418 -5.9502 -11.9668 -7.28223 -17.499 -2.9209c-8.5957 6.77637 -31.8145 22.5381 -61.708 22.5381c-48.3037 0 -77.916 -35.3301 -77.916 -80.082c0 -41.5889 26.8877 -83.6924 78.2764 -83.6924
|
||||
c32.6572 0 56.8428 19.0391 65.7266 27.2256c5.26953 4.85645 13.5957 4.03906 17.8193 -1.73828l19.8652 -27.1699c1.45996 -1.98145 2.32422 -4.42969 2.32422 -7.07715c0 -3.28809 -1.32422 -6.2793 -3.47656 -8.44043z" />
|
||||
<glyph glyph-name="closed-captioning" unicode=""
|
||||
d="M464 384c26.5 0 48 -21.5 48 -48v-288c0 -26.5 -21.5 -48 -48 -48h-416c-26.5 0 -48 21.5 -48 48v288c0 26.5 21.5 48 48 48h416zM458 48c3.2998 0 6 2.7002 6 6v276c0 3.2998 -2.7002 6 -6 6h-404c-3.2998 0 -6 -2.7002 -6 -6v-276c0 -3.2998 2.7002 -6 6 -6h404z
|
||||
M246.9 133.7c1.69922 -2.40039 1.5 -5.60059 -0.5 -7.7002c-53.6006 -56.7998 -172.801 -32.0996 -172.801 67.9004c0 97.2998 121.7 119.5 172.5 70.0996c2.10059 -2 2.5 -3.2002 1 -5.7002l-17.5 -30.5c-1.89941 -3.09961 -6.19922 -4 -9.09961 -1.7002
|
||||
c-40.7998 32 -94.5996 14.9004 -94.5996 -31.1992c0 -48 51 -70.5 92.1992 -32.6006c2.80078 2.5 7.10059 2.10059 9.2002 -0.899414zM437.3 133.7c1.7002 -2.40039 1.5 -5.60059 -0.5 -7.7002c-53.5996 -56.9004 -172.8 -32.0996 -172.8 67.9004
|
||||
c0 97.2998 121.7 119.5 172.5 70.0996c2.09961 -2 2.5 -3.2002 1 -5.7002l-17.5 -30.5c-1.90039 -3.09961 -6.2002 -4 -9.09961 -1.7002c-40.8008 32 -94.6006 14.9004 -94.6006 -31.1992c0 -48 51 -70.5 92.2002 -32.6006c2.7998 2.5 7.09961 2.10059 9.2002 -0.899414z
|
||||
" />
|
||||
<glyph glyph-name="object-group" unicode=""
|
||||
d="M500 320h-12v-256h12c6.62695 0 12 -5.37305 12 -12v-72c0 -6.62695 -5.37305 -12 -12 -12h-72c-6.62695 0 -12 5.37305 -12 12v12h-320v-12c0 -6.62695 -5.37305 -12 -12 -12h-72c-6.62695 0 -12 5.37305 -12 12v72c0 6.62695 5.37305 12 12 12h12v256h-12
|
||||
c-6.62695 0 -12 5.37305 -12 12v72c0 6.62695 5.37305 12 12 12h72c6.62695 0 12 -5.37305 12 -12v-12h320v12c0 6.62695 5.37305 12 12 12h72c6.62695 0 12 -5.37305 12 -12v-72c0 -6.62695 -5.37305 -12 -12 -12zM448 384v-32h32v32h-32zM32 384v-32h32v32h-32zM64 0v32
|
||||
h-32v-32h32zM480 0v32h-32v-32h32zM440 64v256h-12c-6.62695 0 -12 5.37305 -12 12v12h-320v-12c0 -6.62695 -5.37305 -12 -12 -12h-12v-256h12c6.62695 0 12 -5.37305 12 -12v-12h320v12c0 6.62695 5.37305 12 12 12h12zM404 256c6.62695 0 12 -5.37207 12 -12v-168
|
||||
c0 -6.62793 -5.37305 -12 -12 -12h-200c-6.62695 0 -12 5.37207 -12 12v52h-84c-6.62695 0 -12 5.37207 -12 12v168c0 6.62793 5.37305 12 12 12h200c6.62695 0 12 -5.37207 12 -12v-52h84zM136 280v-112h144v112h-144zM376 104v112h-56v-76
|
||||
c0 -6.62793 -5.37305 -12 -12 -12h-76v-24h144z" />
|
||||
<glyph glyph-name="object-ungroup" unicode="" horiz-adv-x="576"
|
||||
d="M564 224h-12v-160h12c6.62695 0 12 -5.37305 12 -12v-72c0 -6.62695 -5.37305 -12 -12 -12h-72c-6.62695 0 -12 5.37305 -12 12v12h-224v-12c0 -6.62695 -5.37305 -12 -12 -12h-72c-6.62695 0 -12 5.37305 -12 12v72c0 6.62695 5.37305 12 12 12h12v24h-88v-12
|
||||
c0 -6.62695 -5.37305 -12 -12 -12h-72c-6.62695 0 -12 5.37305 -12 12v72c0 6.62695 5.37305 12 12 12h12v160h-12c-6.62695 0 -12 5.37305 -12 12v72c0 6.62695 5.37305 12 12 12h72c6.62695 0 12 -5.37305 12 -12v-12h224v12c0 6.62695 5.37305 12 12 12h72
|
||||
c6.62695 0 12 -5.37305 12 -12v-72c0 -6.62695 -5.37305 -12 -12 -12h-12v-24h88v12c0 6.62695 5.37305 12 12 12h72c6.62695 0 12 -5.37305 12 -12v-72c0 -6.62695 -5.37305 -12 -12 -12zM352 384v-32h32v32h-32zM352 128v-32h32v32h-32zM64 96v32h-32v-32h32zM64 352v32
|
||||
h-32v-32h32zM96 136h224v12c0 6.62695 5.37305 12 12 12h12v160h-12c-6.62695 0 -12 5.37305 -12 12v12h-224v-12c0 -6.62695 -5.37305 -12 -12 -12h-12v-160h12c6.62695 0 12 -5.37305 12 -12v-12zM224 0v32h-32v-32h32zM504 64v160h-12c-6.62695 0 -12 5.37305 -12 12v12
|
||||
h-88v-88h12c6.62695 0 12 -5.37305 12 -12v-72c0 -6.62695 -5.37305 -12 -12 -12h-72c-6.62695 0 -12 5.37305 -12 12v12h-88v-24h12c6.62695 0 12 -5.37305 12 -12v-12h224v12c0 6.62695 5.37305 12 12 12h12zM544 0v32h-32v-32h32zM544 256v32h-32v-32h32z" />
|
||||
<glyph glyph-name="sticky-note" unicode="" horiz-adv-x="448"
|
||||
d="M448 99.8936c0 -13.2451 -5.37012 -25.252 -14.0586 -33.9404l-83.8828 -83.8818c-8.68848 -8.68848 -20.6943 -14.0596 -33.9404 -14.0596h-268.118c-26.5098 0 -48 21.4902 -48 48v351.988c0 26.5098 21.4902 48 48 48h352c26.5098 0 48 -21.4902 48 -48v-268.106z
|
||||
M320 19.8936l76.1182 76.1182h-76.1182v-76.1182zM400 368h-352v-351.988h224v104c0 13.2549 10.7451 24 24 24h104v223.988z" />
|
||||
<glyph glyph-name="clone" unicode=""
|
||||
d="M464 448c26.5098 0 48 -21.4902 48 -48v-320c0 -26.5098 -21.4902 -48 -48 -48h-48v-48c0 -26.5098 -21.4902 -48 -48 -48h-320c-26.5098 0 -48 21.4902 -48 48v320c0 26.5098 21.4902 48 48 48h48v48c0 26.5098 21.4902 48 48 48h320zM362 -16c3.31152 0 6 2.68848 6 6
|
||||
v42h-224c-26.5098 0 -48 21.4902 -48 48v224h-42c-3.31152 0 -6 -2.68848 -6 -6v-308c0 -3.31152 2.68848 -6 6 -6h308zM458 80c3.31152 0 6 2.68848 6 6v308c0 3.31152 -2.68848 6 -6 6h-308c-3.31152 0 -6 -2.68848 -6 -6v-308c0 -3.31152 2.68848 -6 6 -6h308z" />
|
||||
<glyph glyph-name="hourglass" unicode="" horiz-adv-x="384"
|
||||
d="M368 400c0 -80.0996 -31.8984 -165.619 -97.1797 -208c64.9912 -42.1934 97.1797 -127.436 97.1797 -208h4c6.62695 0 12 -5.37305 12 -12v-24c0 -6.62695 -5.37305 -12 -12 -12h-360c-6.62695 0 -12 5.37305 -12 12v24c0 6.62695 5.37305 12 12 12h4
|
||||
c0 80.0996 31.8994 165.619 97.1797 208c-64.9912 42.1934 -97.1797 127.436 -97.1797 208h-4c-6.62695 0 -12 5.37305 -12 12v24c0 6.62695 5.37305 12 12 12h360c6.62695 0 12 -5.37305 12 -12v-24c0 -6.62695 -5.37305 -12 -12 -12h-4zM64 400
|
||||
c0 -101.621 57.3066 -184 128 -184s128 82.3799 128 184h-256zM320 -16c0 101.62 -57.3076 184 -128 184s-128 -82.3799 -128 -184h256z" />
|
||||
<glyph glyph-name="hand-rock" unicode=""
|
||||
d="M408.864 368.948c48.8213 20.751 103.136 -15.0723 103.136 -67.9111v-114.443c0 -15.3955 -3.08887 -30.3906 -9.18262 -44.5674l-42.835 -99.6562c-4.99707 -11.625 -3.98242 -18.8574 -3.98242 -42.3701c0 -17.6729 -14.3271 -32 -32 -32h-252
|
||||
c-17.6729 0 -32 14.3271 -32 32c0 27.3301 1.1416 29.2012 -3.11035 32.9033l-97.71 85.0811c-24.8994 21.6797 -39.1797 52.8926 -39.1797 85.6338v56.9531c0 47.4277 44.8457 82.0215 91.0459 71.1807c1.96094 55.751 63.5107 87.8262 110.671 60.8057
|
||||
c29.1895 31.0713 78.8604 31.4473 108.334 -0.0214844c32.7051 18.6846 76.4121 10.3096 98.8135 -23.5879zM464 186.594v114.445c0 34.29 -52 33.8232 -52 0.676758c0 -8.83594 -7.16309 -16 -16 -16h-7c-8.83691 0 -16 7.16406 -16 16v26.751
|
||||
c0 34.457 -52 33.707 -52 0.676758v-27.4287c0 -8.83594 -7.16309 -16 -16 -16h-7c-8.83691 0 -16 7.16406 -16 16v40.4658c0 34.3525 -52 33.8115 -52 0.677734v-41.1436c0 -8.83594 -7.16406 -16 -16 -16h-7c-8.83594 0 -16 7.16406 -16 16v26.751
|
||||
c0 34.4023 -52 33.7744 -52 0.676758v-116.571c0 -8.83105 -7.17773 -15.9961 -16.0078 -15.9961c-4.0166 0 -7.68848 1.48242 -10.499 3.92969l-7 6.09473c-3.37012 2.93457 -5.49316 7.25293 -5.49316 12.0674v41.2275c0 34.2148 -52 33.8857 -52 0.677734v-56.9531
|
||||
c0 -18.8555 8.27441 -36.874 22.7002 -49.4365l97.71 -85.0801c12.4502 -10.8398 19.5898 -26.4463 19.5898 -42.8164v-10.2861h220v7.07617c0 13.21 2.65332 26.0791 7.88281 38.25l42.835 99.6553c3.37891 7.82715 5.28223 16.501 5.28223 25.5625v0.0498047z" />
|
||||
<glyph glyph-name="hand-paper" unicode="" horiz-adv-x="448"
|
||||
d="M372.57 335.359c39.9062 5.63281 75.4297 -25.7393 75.4297 -66.3594v-131.564c-0.00292969 -15.7393 -1.80566 -30.9482 -5.19531 -45.666l-30.1836 -130.958c-3.34668 -14.5234 -16.2783 -24.8125 -31.1816 -24.8125h-222.897
|
||||
c-10.7539 0 -20.2588 5.28613 -26.0615 13.4316l-119.97 168.415c-21.2441 29.8203 -14.8047 71.3574 14.5498 93.1533c18.7754 13.9395 42.1309 16.2979 62.083 8.87109v126.13c0 44.0547 41.125 75.5439 82.4053 64.9834c23.8926 48.1963 92.3535 50.2471 117.982 0.74707
|
||||
c42.5186 11.1445 83.0391 -21.9346 83.0391 -65.5469v-10.8242zM399.997 137.437l-0.00195312 131.563c0 24.9492 -36.5703 25.5508 -36.5703 -0.691406v-76.3086c0 -8.83691 -7.16309 -16 -16 -16h-6.85645c-8.83691 0 -16 7.16309 -16 16v154.184
|
||||
c0 25.501 -36.5703 26.3633 -36.5703 0.691406v-154.875c0 -8.83691 -7.16309 -16 -16 -16h-6.85645c-8.83691 0 -16 7.16309 -16 16v188.309c0 25.501 -36.5703 26.3545 -36.5703 0.691406v-189c0 -8.83691 -7.16309 -16 -16 -16h-6.85645c-8.83691 0 -16 7.16309 -16 16
|
||||
v153.309c0 25.501 -36.5713 26.3359 -36.5713 0.691406v-206.494c0 -15.5703 -20.0352 -21.9092 -29.0303 -9.2832l-27.1279 38.0791c-14.3711 20.1709 -43.833 -2.33496 -29.3945 -22.6045l115.196 -161.697h201.92l27.3252 118.551
|
||||
c2.63086 11.417 3.96484 23.1553 3.96484 34.8857z" />
|
||||
<glyph glyph-name="hand-scissors" unicode=""
|
||||
d="M256 -32c-44.9561 0 -77.3428 43.2627 -64.0244 85.8535c-21.6484 13.71 -34.0156 38.7617 -30.3408 65.0068h-87.6348c-40.8037 0 -74 32.8105 -74 73.1406c0 40.3291 33.1963 73.1396 74 73.1396l94 -9.14062l-78.8496 18.6787
|
||||
c-38.3076 14.7422 -57.04 57.4707 -41.9424 95.1123c15.0303 37.4736 57.7549 55.7803 95.6416 41.2012l144.929 -55.7568c24.9551 30.5566 57.8086 43.9932 92.2178 24.7324l97.999 -54.8525c20.9746 -11.7393 34.0049 -33.8457 34.0049 -57.6904v-205.702
|
||||
c0 -30.7422 -21.4404 -57.5576 -51.7979 -64.5537l-118.999 -27.4268c-4.97168 -1.14648 -10.0889 -1.72949 -15.2031 -1.72949zM256 16.0127l70 -0.000976562c1.52441 0 2.99707 0.174805 4.42285 0.501953l119.001 27.4277
|
||||
c8.58203 1.97754 14.5762 9.29102 14.5762 17.7812v205.701c0 6.4873 -3.62109 12.542 -9.44922 15.8047l-98 54.8545c-8.13965 4.55566 -18.668 2.61914 -24.4873 -4.50781l-21.7646 -26.6475c-2.93457 -3.59375 -7.40332 -5.87305 -12.4004 -5.87305
|
||||
c-2.02246 0 -3.95703 0.375977 -5.73828 1.06152l-166.549 64.0908c-32.6543 12.5664 -50.7744 -34.5771 -19.2227 -46.7168l155.357 -59.7852c6 -2.30859 10.2539 -8.12402 10.2539 -14.9326v-11.6328c0 -8.83691 -7.16309 -16 -16 -16h-182
|
||||
c-34.375 0 -34.4297 -50.2803 0 -50.2803h182c8.83691 0 16 -7.16309 16 -16v-6.85645c0 -8.83691 -7.16309 -16 -16 -16h-28c-25.1221 0 -25.1592 -36.5674 0 -36.5674h28c8.83691 0 16 -7.16211 16 -16v-6.85547c0 -8.83691 -7.16309 -16 -16 -16
|
||||
c-25.1201 0 -25.1602 -36.5674 0 -36.5674z" />
|
||||
<glyph glyph-name="hand-lizard" unicode="" horiz-adv-x="576"
|
||||
d="M556.686 157.458c12.6357 -19.4863 19.3145 -42.0615 19.3145 -65.2871v-124.171h-224v71.582l-99.751 38.7871c-2.7832 1.08203 -5.70996 1.63086 -8.69727 1.63086h-131.552c-30.8789 0 -56 25.1211 -56 56c0 48.5234 39.4766 88 88 88h113.709l18.333 48h-196.042
|
||||
c-44.1123 0 -80 35.8877 -80 80v8c0 30.8779 25.1211 56 56 56h293.917c24.5 0 47.084 -12.2725 60.4111 -32.8291zM528 16v76.1709c0 0.0166016 -0.0439453 0.106445 -0.0439453 0.12207c0 14.3945 -4.24219 27.8057 -11.5439 39.0498l-146.358 225.715
|
||||
c-4.44336 6.85254 -11.9707 10.9424 -20.1367 10.9424h-293.917c-4.41113 0 -8 -3.58887 -8 -8v-8c0 -17.6445 14.3555 -32 32 -32h213.471c25.2021 0 42.626 -25.293 33.6299 -48.8457l-24.5518 -64.2812c-7.05371 -18.4658 -25.0732 -30.873 -44.8398 -30.873h-113.709
|
||||
c-22.0557 0 -40 -17.9443 -40 -40c0 -4.41113 3.58887 -8 8 -8h131.552c0.0175781 0 0.0712891 -0.0273438 0.0888672 -0.0273438c9.16992 0 17.9404 -1.72461 26.0039 -4.86621l99.752 -38.7881c18.5898 -7.22852 30.6035 -24.7881 30.6035 -44.7363v-23.582h128z" />
|
||||
<glyph glyph-name="hand-spock" unicode=""
|
||||
d="M501.03 331.824c6.92773 -11.1826 10.9697 -24.4053 10.9697 -38.5146c0 -5.92676 -0.706055 -11.6885 -2.03809 -17.208l-57.623 -241.963c-13.2236 -56.1904 -63.707 -98.1387 -123.908 -98.1387h-0.352539h-107.455
|
||||
c-0.0761719 0 -0.193359 0.00195312 -0.270508 0.00195312c-40.9248 0 -78.1475 15.9814 -105.761 42.0391l-91.3652 85.9766c-14.3076 13.4434 -23.2246 32.5547 -23.2246 53.7168c0 19.5254 7.61035 37.2861 20.0254 50.4766
|
||||
c5.31836 5.66406 29.875 29.3926 68.1152 21.8477l-24.3594 82.1973c-1.97363 6.64844 -2.97656 13.6836 -2.97656 20.9688c0 38.6953 29.8926 70.4639 67.8262 73.4531c-0.246094 2.45117 -0.34082 4.85547 -0.34082 7.37207c0 34.4199 23.585 63.376 55.4619 71.5752
|
||||
c43.248 10.9785 80.5645 -17.7012 89.6602 -53.0723l13.6836 -53.207l4.64648 22.6602c6.99023 33.5186 36.6826 58.8037 72.2373 58.916c8.73438 0 56.625 -3.26953 70.7383 -54.0801c15.0664 0.710938 46.9199 -3.50977 66.3105 -35.0176zM463.271 287.219
|
||||
c7.86914 32.9844 -42.1211 45.2695 -50.0859 11.9219l-24.8008 -104.146c-4.38867 -18.4141 -31.7783 -11.8926 -28.0557 6.2168l28.5479 139.166c7.39844 36.0703 -43.3076 45.0703 -50.1182 11.9629l-31.791 -154.971
|
||||
c-3.54883 -17.3086 -28.2832 -18.0469 -32.7109 -0.804688l-47.3262 184.035c-8.43359 32.8105 -58.3691 20.2676 -49.8652 -12.8359l42.4414 -165.039c4.81641 -18.7207 -23.3711 -26.9121 -28.9648 -8.00781l-31.3438 105.779
|
||||
c-9.6875 32.6465 -59.1191 18.2578 -49.3867 -14.625l36.0137 -121.539c6.59375 -22.2441 10.1777 -45.7803 10.1777 -70.1523c0 -6.54297 -8.05664 -10.9355 -13.4824 -5.82617l-51.123 48.1074c-24.7852 23.4082 -60.0527 -14.1875 -35.2793 -37.4902l91.3691 -85.9805
|
||||
c19.0469 -17.9736 44.75 -28.998 72.9795 -28.998h0.157227h107.455c0.0732422 0 0.138672 0.0429688 0.212891 0.0429688c37.5791 0 69.1016 26.1416 77.3564 61.2168z" />
|
||||
<glyph glyph-name="hand-pointer" unicode="" horiz-adv-x="448"
|
||||
d="M358.182 268.639c43.1934 16.6348 89.8184 -15.7949 89.8184 -62.6387v-84c-0.000976562 -5.24023 -0.600586 -10.3037 -1.72754 -15.2041l-27.4297 -118.999c-6.98242 -30.2969 -33.7549 -51.7969 -64.5566 -51.7969h-178.286
|
||||
c-21.2588 0 -41.3682 10.4102 -53.791 27.8457l-109.699 154.001c-21.2432 29.8193 -14.8047 71.3574 14.5498 93.1523c18.8115 13.9658 42.1748 16.2822 62.083 8.87207v161.129c0 36.9443 29.7363 67 66.2861 67s66.2861 -30.0557 66.2861 -67v-73.6338
|
||||
c20.4131 2.85742 41.4678 -3.94238 56.5947 -19.6289c27.1934 12.8467 60.3799 5.66992 79.8721 -19.0986zM80.9854 168.303c-14.4004 20.2119 -43.8008 -2.38281 -29.3945 -22.6055l109.712 -154c3.43457 -4.81934 8.92871 -7.69727 14.6973 -7.69727h178.285
|
||||
c8.49219 0 15.8037 5.99414 17.7822 14.5762l27.4297 119.001c0.333008 1.44629 0.501953 2.93457 0.501953 4.42285v84c0 25.1602 -36.5713 25.1211 -36.5713 0c0 -8.83594 -7.16309 -16 -16 -16h-6.85645c-8.83691 0 -16 7.16406 -16 16v21
|
||||
c0 25.1602 -36.5713 25.1201 -36.5713 0v-21c0 -8.83594 -7.16309 -16 -16 -16h-6.85938c-8.83691 0 -16 7.16406 -16 16v35c0 25.1602 -36.5703 25.1201 -36.5703 0v-35c0 -8.83594 -7.16309 -16 -16 -16h-6.85742c-8.83691 0 -16 7.16406 -16 16v175
|
||||
c0 25.1602 -36.5713 25.1201 -36.5713 0v-241.493c0 -15.5703 -20.0352 -21.9092 -29.0303 -9.2832zM176.143 48v96c0 8.83691 6.26855 16 14 16h6c7.73242 0 14 -7.16309 14 -16v-96c0 -8.83691 -6.26758 -16 -14 -16h-6c-7.73242 0 -14 7.16309 -14 16zM251.571 48v96
|
||||
c0 8.83691 6.26758 16 14 16h6c7.73145 0 14 -7.16309 14 -16v-96c0 -8.83691 -6.26855 -16 -14 -16h-6c-7.73242 0 -14 7.16309 -14 16zM327 48v96c0 8.83691 6.26758 16 14 16h6c7.73242 0 14 -7.16309 14 -16v-96c0 -8.83691 -6.26758 -16 -14 -16h-6
|
||||
c-7.73242 0 -14 7.16309 -14 16z" />
|
||||
<glyph glyph-name="hand-peace" unicode="" horiz-adv-x="448"
|
||||
d="M362.146 256.024c42.5908 13.3184 85.8535 -19.0684 85.8535 -64.0244l-0.0117188 -70.001c-0.000976562 -5.24023 -0.600586 -10.3027 -1.72949 -15.2031l-27.4268 -118.999c-6.99707 -30.3564 -33.8105 -51.7969 -64.5547 -51.7969h-205.702
|
||||
c-23.8447 0 -45.9502 13.0303 -57.6904 34.0059l-54.8525 97.999c-19.2607 34.4092 -5.82422 67.2617 24.7324 92.2178l-55.7568 144.928c-14.5791 37.8867 3.72754 80.6113 41.2012 95.6416c37.6406 15.0977 80.3691 -3.63477 95.1123 -41.9424l18.6787 -78.8496
|
||||
l-9.14062 94c0 40.8037 32.8096 74 73.1396 74s73.1406 -33.1963 73.1406 -74v-87.6348c26.2451 3.6748 51.2959 -8.69238 65.0068 -30.3408zM399.987 122l-0.000976562 70c0 25.1602 -36.5674 25.1201 -36.5674 0c0 -8.83691 -7.16309 -16 -16 -16h-6.85547
|
||||
c-8.83789 0 -16 7.16309 -16 16v28c0 25.1592 -36.5674 25.1221 -36.5674 0v-28c0 -8.83691 -7.16309 -16 -16 -16h-6.85645c-8.83691 0 -16 7.16309 -16 16v182c0 34.4297 -50.2803 34.375 -50.2803 0v-182c0 -8.83691 -7.16309 -16 -16 -16h-11.6328
|
||||
c-6.80859 0 -12.624 4.25391 -14.9326 10.2539l-59.7842 155.357c-12.1396 31.5518 -59.2842 13.4326 -46.7168 -19.2227l64.0898 -166.549c0.685547 -1.78125 1.07812 -3.71875 1.07812 -5.74121c0 -4.99707 -2.2959 -9.46289 -5.88965 -12.3975l-26.6475 -21.7646
|
||||
c-7.12695 -5.81934 -9.06445 -16.3467 -4.50781 -24.4873l54.8535 -98c3.26367 -5.82812 9.31934 -9.44922 15.8057 -9.44922h205.701c8.49121 0 15.8037 5.99414 17.7812 14.5762l27.4277 119.001c0.333008 1.44629 0.501953 2.93457 0.501953 4.42285z" />
|
||||
<glyph glyph-name="registered" unicode=""
|
||||
d="M256 440c136.967 0 248 -111.033 248 -248s-111.033 -248 -248 -248s-248 111.033 -248 248s111.033 248 248 248zM256 -8c110.549 0 200 89.4678 200 200c0 110.549 -89.4678 200 -200 200c-110.549 0 -200 -89.4688 -200 -200c0 -110.549 89.4678 -200 200 -200z
|
||||
M366.442 73.791c4.40332 -7.99219 -1.37012 -17.791 -10.5107 -17.791h-42.8096c-0.00488281 0 -0.000976562 -0.0126953 -0.00585938 -0.0126953c-4.58594 0 -8.57422 2.58301 -10.5869 6.37305l-47.5156 89.3027h-31.958v-83.6631c0 -6.61719 -5.38281 -12 -12 -12
|
||||
h-38.5674c-6.61719 0 -12 5.38281 -12 12v248.304c0 6.61719 5.38281 12 12 12h78.667c71.251 0 101.498 -32.749 101.498 -85.252c0 -31.6123 -15.2148 -59.2969 -39.4824 -73.1758c3.02148 -4.61719 0.225586 0.199219 53.2715 -96.085zM256.933 208.094
|
||||
c20.9131 0 32.4307 11.5186 32.4316 32.4316c0 19.5752 -6.5127 31.709 -38.9297 31.709h-27.377v-64.1406h33.875z" />
|
||||
<glyph glyph-name="calendar-plus" unicode="" horiz-adv-x="448"
|
||||
d="M336 156v-24c0 -6.59961 -5.40039 -12 -12 -12h-76v-76c0 -6.59961 -5.40039 -12 -12 -12h-24c-6.59961 0 -12 5.40039 -12 12v76h-76c-6.59961 0 -12 5.40039 -12 12v24c0 6.59961 5.40039 12 12 12h76v76c0 6.59961 5.40039 12 12 12h24c6.59961 0 12 -5.40039 12 -12
|
||||
v-76h76c6.59961 0 12 -5.40039 12 -12zM448 336v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h48v52c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-52h128v52c0 6.59961 5.40039 12 12 12h40
|
||||
c6.59961 0 12 -5.40039 12 -12v-52h48c26.5 0 48 -21.5 48 -48zM400 -10v298h-352v-298c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="calendar-minus" unicode="" horiz-adv-x="448"
|
||||
d="M124 120c-6.59961 0 -12 5.40039 -12 12v24c0 6.59961 5.40039 12 12 12h200c6.59961 0 12 -5.40039 12 -12v-24c0 -6.59961 -5.40039 -12 -12 -12h-200zM448 336v-352c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h48v52
|
||||
c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-52h128v52c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-52h48c26.5 0 48 -21.5 48 -48zM400 -10v298h-352v-298c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="calendar-times" unicode="" horiz-adv-x="448"
|
||||
d="M311.7 73.2998l-17 -17c-4.7002 -4.7002 -12.2998 -4.7002 -17 0l-53.7002 53.7998l-53.7002 -53.6992c-4.7002 -4.7002 -12.2998 -4.7002 -17 0l-17 17c-4.7002 4.69922 -4.7002 12.2998 0 17l53.7002 53.6992l-53.7002 53.7002c-4.7002 4.7002 -4.7002 12.2998 0 17
|
||||
l17 17c4.7002 4.7002 12.2998 4.7002 17 0l53.7002 -53.7002l53.7002 53.7002c4.7002 4.7002 12.2998 4.7002 17 0l17 -17c4.7002 -4.7002 4.7002 -12.2998 0 -17l-53.7998 -53.7998l53.6992 -53.7002c4.80078 -4.7002 4.80078 -12.2998 0.100586 -17zM448 336v-352
|
||||
c0 -26.5 -21.5 -48 -48 -48h-352c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h48v52c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-52h128v52c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-52h48c26.5 0 48 -21.5 48 -48zM400 -10
|
||||
v298h-352v-298c0 -3.2998 2.7002 -6 6 -6h340c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="calendar-check" unicode="" horiz-adv-x="448"
|
||||
d="M400 384c26.5098 0 48 -21.4902 48 -48v-352c0 -26.5098 -21.4902 -48 -48 -48h-352c-26.5098 0 -48 21.4902 -48 48v352c0 26.5098 21.4902 48 48 48h48v52c0 6.62695 5.37305 12 12 12h40c6.62695 0 12 -5.37305 12 -12v-52h128v52c0 6.62695 5.37305 12 12 12h40
|
||||
c6.62695 0 12 -5.37305 12 -12v-52h48zM394 -16c3.31152 0 6 2.68848 6 6v298h-352v-298c0 -3.31152 2.68848 -6 6 -6h340zM341.151 184.65l-142.31 -141.169c-4.70508 -4.66699 -12.3027 -4.6377 -16.9707 0.0673828l-75.0908 75.6992
|
||||
c-4.66699 4.70508 -4.6377 12.3027 0.0673828 16.9707l22.7197 22.5361c4.70508 4.66699 12.3027 4.63672 16.9697 -0.0693359l44.1035 -44.4609l111.072 110.182c4.70508 4.66699 12.3027 4.63672 16.9707 -0.0683594l22.5361 -22.7178
|
||||
c4.66699 -4.70508 4.63672 -12.3027 -0.0683594 -16.9697z" />
|
||||
<glyph glyph-name="map" unicode="" horiz-adv-x="576"
|
||||
d="M560.02 416c8.4502 0 15.9805 -6.83008 15.9805 -16.0195v-346.32c0 -13.4707 -8.32422 -24.9951 -20.1201 -29.71l-151.83 -52.8105c-6.23242 -2.02832 -12.9023 -3.12305 -19.8076 -3.12305c-7.07324 0 -13.8799 1.15039 -20.2422 3.27344l-172 60.71l-170.05 -62.8398
|
||||
c-1.99023 -0.790039 -4 -1.16016 -5.95996 -1.16016c-8.45996 0 -15.9902 6.83008 -15.9902 16.0195v346.32c0.00292969 13.4697 8.32617 24.9932 20.1201 29.71l151.83 52.8105c6.43945 2.08984 13.1201 3.13965 19.8096 3.13965
|
||||
c7.06641 -0.00292969 13.8789 -1.16602 20.2402 -3.28027l172 -60.7197h0.00976562l170.05 62.8398c1.98047 0.790039 4 1.16016 5.95996 1.16016zM224 357.58v-285.97l128 -45.1904v285.97zM48 29.9502l127.36 47.0801l0.639648 0.229492v286.2l-128 -44.5303v-288.979z
|
||||
M528 65.0801v288.97l-127.36 -47.0693l-0.639648 -0.240234v-286.19z" />
|
||||
<glyph glyph-name="comment-alt" unicode=""
|
||||
d="M448 448c35.2998 0 64 -28.7002 64 -64v-288c0 -35.2998 -28.7002 -64 -64 -64h-144l-124.9 -93.5996c-2.19922 -1.7002 -4.69922 -2.40039 -7.09961 -2.40039c-6.2002 0 -12 4.90039 -12 12v84h-96c-35.2998 0 -64 28.7002 -64 64v288c0 35.2998 28.7002 64 64 64h384z
|
||||
M464 96v288c0 8.7998 -7.2002 16 -16 16h-384c-8.7998 0 -16 -7.2002 -16 -16v-288c0 -8.7998 7.2002 -16 16 -16h144v-60l67.2002 50.4004l12.7998 9.59961h160c8.7998 0 16 7.2002 16 16z" />
|
||||
<glyph glyph-name="pause-circle" unicode=""
|
||||
d="M256 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM256 -8c110.5 0 200 89.5 200 200s-89.5 200 -200 200s-200 -89.5 -200 -200s89.5 -200 200 -200zM352 272v-160c0 -8.7998 -7.2002 -16 -16 -16h-48
|
||||
c-8.7998 0 -16 7.2002 -16 16v160c0 8.7998 7.2002 16 16 16h48c8.7998 0 16 -7.2002 16 -16zM240 272v-160c0 -8.7998 -7.2002 -16 -16 -16h-48c-8.7998 0 -16 7.2002 -16 16v160c0 8.7998 7.2002 16 16 16h48c8.7998 0 16 -7.2002 16 -16z" />
|
||||
<glyph glyph-name="stop-circle" unicode=""
|
||||
d="M504 192c0 -137 -111 -248 -248 -248s-248 111 -248 248s111 248 248 248s248 -111 248 -248zM56 192c0 -110.5 89.5 -200 200 -200s200 89.5 200 200s-89.5 200 -200 200s-200 -89.5 -200 -200zM352 272v-160c0 -8.7998 -7.2002 -16 -16 -16h-160
|
||||
c-8.7998 0 -16 7.2002 -16 16v160c0 8.7998 7.2002 16 16 16h160c8.7998 0 16 -7.2002 16 -16z" />
|
||||
<glyph glyph-name="handshake" unicode="" horiz-adv-x="640"
|
||||
d="M519.2 320.1h120.8v-255.699h-64c-17.5 0 -31.7998 14.1992 -31.9004 31.6992h-57.8994c-1.7998 -8.19922 -5.2998 -16.0996 -10.9004 -23l-26.2002 -32.2998c-15.7998 -19.3994 -41.8994 -25.5 -64 -16.7998c-13.5 -16.5996 -30.5996 -24 -48.7998 -24
|
||||
c-15.0996 0 -28.5996 5.09961 -41.0996 15.9004c-31.7998 -21.9004 -74.7002 -21.3008 -105.601 3.7998l-84.5996 76.3994h-9.09961c-0.100586 -17.5 -14.3008 -31.6992 -31.9004 -31.6992h-64v255.699h118l47.5996 47.6006c10.5 10.3994 24.8008 16.2998 39.6006 16.2998
|
||||
h226.8c15.4326 0 29.4326 -6.22168 39.5996 -16.2998zM48 96.4004c8.7998 0 16 7.09961 16 16c0 8.7998 -7.2002 16 -16 16s-16 -7.2002 -16 -16c0 -8.80078 7.2002 -16 16 -16zM438 103.3c2.7002 3.40039 2.2002 8.5 -1.2002 11.2998l-108.2 87.8008l-8.19922 -7.5
|
||||
c-40.3008 -36.8008 -86.7002 -11.8008 -101.5 4.39941c-26.7002 29 -25 74.4004 4.39941 101.3l38.7002 35.5h-56.7002c-2 -0.799805 -3.7002 -1.5 -5.7002 -2.2998l-61.6992 -61.5996h-41.9004v-128.101h27.7002l97.2998 -88
|
||||
c16.0996 -13.0996 41.4004 -10.5 55.2998 6.60059l15.6006 19.2002l36.7998 -31.5c3 -2.40039 12 -4.90039 18 2.39941l30 36.5l23.8994 -19.3994c3.5 -2.80078 8.5 -2.2002 11.3008 1.19922zM544 144.1v128h-44.7002l-61.7002 61.6006
|
||||
c-1.39941 1.5 -3.39941 2.2998 -5.5 2.2998l-83.6992 -0.200195c-10 0 -19.6006 -3.7002 -27 -10.5l-65.6006 -60.0996c-9.7002 -8.7998 -10.5 -24 -1.2002 -33.9004c8.90039 -9.39941 25.1006 -8.7002 34.6006 0l55.2002 50.6006c6.5 5.89941 16.5996 5.5 22.5996 -1
|
||||
l10.9004 -11.7002c6 -6.5 5.5 -16.6006 -1 -22.6006l-12.5 -11.3994l102.699 -83.4004c2.80078 -2.2998 5.40039 -4.89941 7.7002 -7.7002h69.2002zM592 96.4004c8.7998 0 16 7.09961 16 16c0 8.7998 -7.2002 16 -16 16s-16 -7.2002 -16 -16c0 -8.80078 7.2002 -16 16 -16z
|
||||
" />
|
||||
<glyph glyph-name="envelope-open" unicode=""
|
||||
d="M494.586 283.484c10.6523 -8.80762 17.4141 -22.1064 17.4141 -36.9932v-262.491c0 -26.5098 -21.4902 -48 -48 -48h-416c-26.5098 0 -48 21.4902 -48 48v262.515c0 14.9355 6.80469 28.2705 17.5146 37.0771c4.08008 3.35449 110.688 89.0996 135.15 108.549
|
||||
c22.6992 18.1426 60.1299 55.8594 103.335 55.8594c43.4365 0 81.2314 -38.1914 103.335 -55.8594c23.5283 -18.707 130.554 -104.773 135.251 -108.656zM464 -10v253.632c0 0.00195312 0.00390625 0.000976562 0.00390625 0.00292969
|
||||
c0 1.88184 -0.869141 3.56152 -2.22754 4.66016c-15.8633 12.8232 -108.793 87.5752 -132.366 106.316c-17.5527 14.0195 -49.7168 45.3887 -73.4102 45.3887c-23.6016 0 -55.2451 -30.8799 -73.4102 -45.3887c-23.5713 -18.7393 -116.494 -93.4795 -132.364 -106.293
|
||||
c-1.40918 -1.13965 -2.22559 -2.85254 -2.22559 -4.66504v-253.653c0 -3.31152 2.68848 -6 6 -6h404c3.31152 0 6 2.68848 6 6zM432.009 177.704c4.24902 -5.15918 3.46484 -12.7949 -1.74512 -16.9814c-28.9746 -23.2822 -59.2734 -47.5967 -70.9287 -56.8623
|
||||
c-22.6992 -18.1436 -60.1299 -55.8604 -103.335 -55.8604c-43.4521 0 -81.2871 38.2373 -103.335 55.8604c-11.2793 8.9668 -41.7441 33.4131 -70.9268 56.8643c-5.20996 4.1875 -5.99316 11.8223 -1.74512 16.9814l15.2578 18.5283
|
||||
c4.17773 5.07227 11.6572 5.84277 16.7793 1.72559c28.6182 -23.001 58.5654 -47.0352 70.5596 -56.5713c17.5527 -14.0195 49.7168 -45.3887 73.4102 -45.3887c23.6016 0 55.2461 30.8799 73.4102 45.3887c11.9941 9.53516 41.9434 33.5703 70.5625 56.5684
|
||||
c5.12207 4.11621 12.6016 3.3457 16.7783 -1.72656z" />
|
||||
<glyph glyph-name="address-book" unicode="" horiz-adv-x="448"
|
||||
d="M436 288h-20v-64h20c6.59961 0 12 -5.40039 12 -12v-40c0 -6.59961 -5.40039 -12 -12 -12h-20v-64h20c6.59961 0 12 -5.40039 12 -12v-40c0 -6.59961 -5.40039 -12 -12 -12h-20v-48c0 -26.5 -21.5 -48 -48 -48h-320c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48
|
||||
h320c26.5 0 48 -21.5 48 -48v-48h20c6.59961 0 12 -5.40039 12 -12v-40c0 -6.59961 -5.40039 -12 -12 -12zM368 -16v416h-320v-416h320zM208 192c-35.2998 0 -64 28.7002 -64 64s28.7002 64 64 64s64 -28.7002 64 -64s-28.7002 -64 -64 -64zM118.4 64
|
||||
c-12.4004 0 -22.4004 8.59961 -22.4004 19.2002v19.2002c0 31.7998 30.0996 57.5996 67.2002 57.5996c11.3994 0 17.8994 -8 44.7998 -8c26.0996 0 34 8 44.7998 8c37.1006 0 67.2002 -25.7998 67.2002 -57.5996v-19.2002c0 -10.6006 -10 -19.2002 -22.4004 -19.2002
|
||||
h-179.199z" />
|
||||
<glyph glyph-name="address-card" unicode="" horiz-adv-x="576"
|
||||
d="M528 416c26.5 0 48 -21.5 48 -48v-352c0 -26.5 -21.5 -48 -48 -48h-480c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h480zM528 16v352h-480v-352h480zM208 192c-35.2998 0 -64 28.7002 -64 64s28.7002 64 64 64s64 -28.7002 64 -64s-28.7002 -64 -64 -64z
|
||||
M118.4 64c-12.4004 0 -22.4004 8.59961 -22.4004 19.2002v19.2002c0 31.7998 30.0996 57.5996 67.2002 57.5996c11.3994 0 17.8994 -8 44.7998 -8c26.0996 0 34 8 44.7998 8c37.1006 0 67.2002 -25.7998 67.2002 -57.5996v-19.2002
|
||||
c0 -10.6006 -10 -19.2002 -22.4004 -19.2002h-179.199zM360 128c-4.40039 0 -8 3.59961 -8 8v16c0 4.40039 3.59961 8 8 8h112c4.40039 0 8 -3.59961 8 -8v-16c0 -4.40039 -3.59961 -8 -8 -8h-112zM360 192c-4.40039 0 -8 3.59961 -8 8v16c0 4.40039 3.59961 8 8 8h112
|
||||
c4.40039 0 8 -3.59961 8 -8v-16c0 -4.40039 -3.59961 -8 -8 -8h-112zM360 256c-4.40039 0 -8 3.59961 -8 8v16c0 4.40039 3.59961 8 8 8h112c4.40039 0 8 -3.59961 8 -8v-16c0 -4.40039 -3.59961 -8 -8 -8h-112z" />
|
||||
<glyph glyph-name="user-circle" unicode="" horiz-adv-x="496"
|
||||
d="M248 344c53 0 96 -43 96 -96s-43 -96 -96 -96s-96 43 -96 96s43 96 96 96zM248 200c26.5 0 48 21.5 48 48s-21.5 48 -48 48s-48 -21.5 -48 -48s21.5 -48 48 -48zM248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8
|
||||
c49.7002 0 95.0996 18.2998 130.1 48.4004c-14.8994 23 -40.3994 38.5 -69.5996 39.5c-20.7998 -6.5 -40.5996 -9.60059 -60.5 -9.60059s-39.7002 3.2002 -60.5 9.60059c-29.2002 -0.900391 -54.7002 -16.5 -69.5996 -39.5c35 -30.1006 80.3994 -48.4004 130.1 -48.4004z
|
||||
M410.7 76.0996c23.3994 32.7002 37.2998 72.7002 37.2998 115.9c0 110.3 -89.7002 200 -200 200s-200 -89.7002 -200 -200c0 -43.2002 13.9004 -83.2002 37.2998 -115.9c24.5 31.4004 62.2002 51.9004 105.101 51.9004c10.1992 0 26.0996 -9.59961 57.5996 -9.59961
|
||||
c31.5996 0 47.4004 9.59961 57.5996 9.59961c43 0 80.7002 -20.5 105.101 -51.9004z" />
|
||||
<glyph glyph-name="id-badge" unicode="" horiz-adv-x="384"
|
||||
d="M336 448c26.5 0 48 -21.5 48 -48v-416c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v416c0 26.5 21.5 48 48 48h288zM336 -16v416h-288v-416h288zM144 336c-8.7998 0 -16 7.2002 -16 16s7.2002 16 16 16h96c8.7998 0 16 -7.2002 16 -16s-7.2002 -16 -16 -16
|
||||
h-96zM192 160c-35.2998 0 -64 28.7002 -64 64s28.7002 64 64 64s64 -28.7002 64 -64s-28.7002 -64 -64 -64zM102.4 32c-12.4004 0 -22.4004 8.59961 -22.4004 19.2002v19.2002c0 31.7998 30.0996 57.5996 67.2002 57.5996c11.3994 0 17.8994 -8 44.7998 -8
|
||||
c26.0996 0 34 8 44.7998 8c37.1006 0 67.2002 -25.7998 67.2002 -57.5996v-19.2002c0 -10.6006 -10 -19.2002 -22.4004 -19.2002h-179.199z" />
|
||||
<glyph glyph-name="id-card" unicode="" horiz-adv-x="576"
|
||||
d="M528 416c26.5 0 48 -21.5 48 -48v-352c0 -26.5 -21.5 -48 -48 -48h-480c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h480zM528 16v288h-480v-288h32.7998c-1 4.5 -0.799805 -3.59961 -0.799805 22.4004c0 31.7998 30.0996 57.5996 67.2002 57.5996
|
||||
c11.3994 0 17.8994 -8 44.7998 -8c26.0996 0 34 8 44.7998 8c37.1006 0 67.2002 -25.7998 67.2002 -57.5996c0 -26 0.0996094 -17.9004 -0.799805 -22.4004h224.8zM360 96c-4.40039 0 -8 3.59961 -8 8v16c0 4.40039 3.59961 8 8 8h112c4.40039 0 8 -3.59961 8 -8v-16
|
||||
c0 -4.40039 -3.59961 -8 -8 -8h-112zM360 160c-4.40039 0 -8 3.59961 -8 8v16c0 4.40039 3.59961 8 8 8h112c4.40039 0 8 -3.59961 8 -8v-16c0 -4.40039 -3.59961 -8 -8 -8h-112zM360 224c-4.40039 0 -8 3.59961 -8 8v16c0 4.40039 3.59961 8 8 8h112
|
||||
c4.40039 0 8 -3.59961 8 -8v-16c0 -4.40039 -3.59961 -8 -8 -8h-112zM192 128c-35.2998 0 -64 28.7002 -64 64s28.7002 64 64 64s64 -28.7002 64 -64s-28.7002 -64 -64 -64z" />
|
||||
<glyph glyph-name="window-maximize" unicode=""
|
||||
d="M464 416c26.5 0 48 -21.5 48 -48v-352c0 -26.5 -21.5 -48 -48 -48h-416c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h416zM464 22v234h-416v-234c0 -3.2998 2.7002 -6 6 -6h404c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="window-minimize" unicode=""
|
||||
d="M480 -32h-448c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32h448c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32z" />
|
||||
<glyph glyph-name="window-restore" unicode=""
|
||||
d="M464 448c26.5 0 48 -21.5 48 -48v-320c0 -26.5 -21.5 -48 -48 -48h-48v-48c0 -26.5 -21.5 -48 -48 -48h-320c-26.5 0 -48 21.5 -48 48v320c0 26.5 21.5 48 48 48h48v48c0 26.5 21.5 48 48 48h320zM368 -16v208h-320v-208h320zM464 80v320h-320v-48h224
|
||||
c26.5 0 48 -21.5 48 -48v-224h48z" />
|
||||
<glyph glyph-name="snowflake" unicode="" horiz-adv-x="448"
|
||||
d="M440.1 92.7998c7.60059 -4.39941 10.1006 -14.2002 5.5 -21.7002l-7.89941 -13.8994c-4.40039 -7.7002 -14 -10.2998 -21.5 -5.90039l-39.2002 23l9.09961 -34.7002c2.30078 -8.5 -2.69922 -17.2998 -11.0996 -19.5996l-15.2002 -4.09961
|
||||
c-8.39941 -2.30078 -17.0996 2.7998 -19.2998 11.2998l-21.2998 81l-71.9004 42.2002v-84.5l58.2998 -59.3008c6.10059 -6.19922 6.10059 -16.3994 0 -22.5996l-11.0996 -11.2998c-6.09961 -6.2002 -16.0996 -6.2002 -22.2002 0l-24.8994 25.3994v-46.0996
|
||||
c0 -8.7998 -7 -16 -15.7002 -16h-15.7002c-8.7002 0 -15.7002 7.2002 -15.7002 16v45.9004l-24.8994 -25.4004c-6.10059 -6.2002 -16.1006 -6.2002 -22.2002 0l-11.1006 11.2998c-6.09961 6.2002 -6.09961 16.4004 0 22.6006l58.3008 59.2998v84.5l-71.9004 -42.2002
|
||||
l-21.2998 -81c-2.2998 -8.5 -10.9004 -13.5996 -19.2998 -11.2998l-15.2002 4.09961c-8.40039 2.2998 -13.2998 11.1006 -11.1006 19.6006l9.10059 34.6992l-39.2002 -23c-7.5 -4.39941 -17.2002 -1.7998 -21.5 5.90039l-7.90039 13.9004
|
||||
c-4.2998 7.69922 -1.69922 17.5 5.80078 21.8994l39.1992 23l-34.0996 9.2998c-8.40039 2.30078 -13.2998 11.1006 -11.0996 19.6006l4.09961 15.5c2.2998 8.5 10.9004 13.5996 19.2998 11.2998l79.7002 -21.7002l71.9004 42.2002l-71.9004 42.2002l-79.7002 -21.7002
|
||||
c-8.39941 -2.2998 -17.0996 2.7998 -19.2998 11.2998l-4.09961 15.5c-2.30078 8.5 2.69922 17.2998 11.0996 19.6006l34.0996 9.09961l-39.1992 23c-7.60059 4.5 -10.1006 14.2002 -5.80078 21.9004l7.90039 13.8994c4.40039 7.7002 14 10.2998 21.5 5.90039l39.2002 -23
|
||||
l-9.10059 34.7002c-2.2998 8.5 2.7002 17.2998 11.1006 19.5996l15.2002 4.09961c8.39941 2.30078 17.0996 -2.7998 19.2998 -11.2998l21.2998 -81l71.9004 -42.2002v84.5l-58.3008 59.3008c-6.09961 6.19922 -6.09961 16.3994 0 22.5996l11.5 11.2998
|
||||
c6.10059 6.2002 16.1006 6.2002 22.2002 0l24.9004 -25.3994v46.0996c0 8.7998 7 16 15.7002 16h15.6992c8.7002 0 15.7002 -7.2002 15.7002 -16v-45.9004l24.9004 25.4004c6.09961 6.2002 16.0996 6.2002 22.2002 0l11.0996 -11.2998
|
||||
c6.09961 -6.2002 6.09961 -16.4004 0 -22.6006l-58.2998 -59.2998v-84.5l71.8994 42.2002l21.3008 81c2.2998 8.5 10.8994 13.5996 19.2998 11.2998l15.2002 -4.09961c8.39941 -2.2998 13.2998 -11.1006 11.0996 -19.6006l-9.09961 -34.6992l39.1992 23
|
||||
c7.5 4.39941 17.2002 1.7998 21.5 -5.90039l7.90039 -13.9004c4.2998 -7.69922 1.7002 -17.5 -5.7998 -21.8994l-39.2002 -23l34.0996 -9.2998c8.40039 -2.30078 13.3008 -11.1006 11.1006 -19.6006l-4.10059 -15.5c-2.2998 -8.5 -10.8994 -13.5996 -19.2998 -11.2998
|
||||
l-79.7002 21.7002l-71.8994 -42.2002l71.7998 -42.2002l79.7002 21.7002c8.39941 2.2998 17.0996 -2.7998 19.2998 -11.2998l4.09961 -15.5c2.30078 -8.5 -2.69922 -17.2998 -11.0996 -19.6006l-34.0996 -9.2998z" />
|
||||
<glyph glyph-name="trash-alt" unicode="" horiz-adv-x="448"
|
||||
d="M268 32c-6.62305 0 -12 5.37695 -12 12v216c0 6.62305 5.37695 12 12 12h24c6.62305 0 12 -5.37695 12 -12v-216c0 -6.62305 -5.37695 -12 -12 -12h-24zM432 368c8.83105 0 16 -7.16895 16 -16v-16c0 -8.83105 -7.16895 -16 -16 -16h-16v-336
|
||||
c0 -26.4922 -21.5078 -48 -48 -48h-288c-26.4922 0 -48 21.5078 -48 48v336h-16c-8.83105 0 -16 7.16895 -16 16v16c0 8.83105 7.16895 16 16 16h82.4102l34.0195 56.7002c8.39258 13.9844 23.6777 23.2998 41.1602 23.2998h100.82
|
||||
c0.0078125 0 -0.015625 0.0517578 -0.0078125 0.0517578c17.4824 0 32.7949 -9.36719 41.1875 -23.3516l34 -56.7002h82.4102zM171.84 397.09l-17.4502 -29.0898h139.221l-17.46 29.0898c-1.0498 1.74707 -2.95898 2.91016 -5.14355 2.91016h-0.00683594h-94
|
||||
c-0.00585938 0 -0.00683594 0.00683594 -0.0126953 0.00683594c-2.18457 0 -4.09766 -1.16992 -5.14746 -2.91699zM368 -16v336h-288v-336h288zM156 32c-6.62305 0 -12 5.37695 -12 12v216c0 6.62305 5.37695 12 12 12h24c6.62305 0 12 -5.37695 12 -12v-216
|
||||
c0 -6.62305 -5.37695 -12 -12 -12h-24z" />
|
||||
<glyph glyph-name="images" unicode="" horiz-adv-x="576"
|
||||
d="M480 32v-16c0 -26.5098 -21.4902 -48 -48 -48h-384c-26.5098 0 -48 21.4902 -48 48v256c0 26.5098 21.4902 48 48 48h16v-48h-10c-3.31152 0 -6 -2.68848 -6 -6v-244c0 -3.31152 2.68848 -6 6 -6h372c3.31152 0 6 2.68848 6 6v10h48zM522 368h-372
|
||||
c-3.31152 0 -6 -2.68848 -6 -6v-244c0 -3.31152 2.68848 -6 6 -6h372c3.31152 0 6 2.68848 6 6v244c0 3.31152 -2.68848 6 -6 6zM528 416c26.5098 0 48 -21.4902 48 -48v-256c0 -26.5098 -21.4902 -48 -48 -48h-384c-26.5098 0 -48 21.4902 -48 48v256
|
||||
c0 26.5098 21.4902 48 48 48h384zM264 304c0 -22.0908 -17.9092 -40 -40 -40s-40 17.9092 -40 40s17.9092 40 40 40s40 -17.9092 40 -40zM192 208l39.5146 39.5146c4.68652 4.68652 12.2842 4.68652 16.9717 0l39.5137 -39.5146l103.515 103.515
|
||||
c4.68652 4.68652 12.2842 4.68652 16.9717 0l71.5137 -71.5146v-80h-288v48z" />
|
||||
<glyph glyph-name="clipboard" unicode="" horiz-adv-x="384"
|
||||
d="M336 384c26.5 0 48 -21.5 48 -48v-352c0 -26.5 -21.5 -48 -48 -48h-288c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h80c0 35.2998 28.7002 64 64 64s64 -28.7002 64 -64h80zM192 408c-13.2998 0 -24 -10.7002 -24 -24s10.7002 -24 24 -24s24 10.7002 24 24
|
||||
s-10.7002 24 -24 24zM336 -10v340c0 3.2998 -2.7002 6 -6 6h-42v-36c0 -6.59961 -5.40039 -12 -12 -12h-168c-6.59961 0 -12 5.40039 -12 12v36h-42c-3.2998 0 -6 -2.7002 -6 -6v-340c0 -3.2998 2.7002 -6 6 -6h276c3.2998 0 6 2.7002 6 6z" />
|
||||
<glyph glyph-name="arrow-alt-circle-down" unicode=""
|
||||
d="M256 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM256 -8c110.5 0 200 89.5 200 200s-89.5 200 -200 200s-200 -89.5 -200 -200s89.5 -200 200 -200zM224 308c0 6.59961 5.40039 12 12 12h40c6.59961 0 12 -5.40039 12 -12v-116
|
||||
h67c10.7002 0 16.0996 -12.9004 8.5 -20.5l-99 -99c-4.7002 -4.7002 -12.2998 -4.7002 -17 0l-99 99c-7.5 7.59961 -2.2002 20.5 8.5 20.5h67v116z" />
|
||||
<glyph glyph-name="arrow-alt-circle-left" unicode=""
|
||||
d="M8 192c0 137 111 248 248 248s248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248zM456 192c0 110.5 -89.5 200 -200 200s-200 -89.5 -200 -200s89.5 -200 200 -200s200 89.5 200 200zM384 212v-40c0 -6.59961 -5.40039 -12 -12 -12h-116v-67
|
||||
c0 -10.7002 -12.9004 -16 -20.5 -8.5l-99 99c-4.7002 4.7002 -4.7002 12.2998 0 17l99 99c7.59961 7.59961 20.5 2.2002 20.5 -8.5v-67h116c6.59961 0 12 -5.40039 12 -12z" />
|
||||
<glyph glyph-name="arrow-alt-circle-right" unicode=""
|
||||
d="M504 192c0 -137 -111 -248 -248 -248s-248 111 -248 248s111 248 248 248s248 -111 248 -248zM56 192c0 -110.5 89.5 -200 200 -200s200 89.5 200 200s-89.5 200 -200 200s-200 -89.5 -200 -200zM128 172v40c0 6.59961 5.40039 12 12 12h116v67
|
||||
c0 10.7002 12.9004 16 20.5 8.5l99 -99c4.7002 -4.7002 4.7002 -12.2998 0 -17l-99 -99c-7.59961 -7.59961 -20.5 -2.2002 -20.5 8.5v67h-116c-6.59961 0 -12 5.40039 -12 12z" />
|
||||
<glyph glyph-name="arrow-alt-circle-up" unicode=""
|
||||
d="M256 -56c-137 0 -248 111 -248 248s111 248 248 248s248 -111 248 -248s-111 -248 -248 -248zM256 392c-110.5 0 -200 -89.5 -200 -200s89.5 -200 200 -200s200 89.5 200 200s-89.5 200 -200 200zM276 64h-40c-6.59961 0 -12 5.40039 -12 12v116h-67
|
||||
c-10.7002 0 -16 12.9004 -8.5 20.5l99 99c4.7002 4.7002 12.2998 4.7002 17 0l99 -99c7.59961 -7.59961 2.2002 -20.5 -8.5 -20.5h-67v-116c0 -6.59961 -5.40039 -12 -12 -12z" />
|
||||
<glyph glyph-name="gem" unicode="" horiz-adv-x="576"
|
||||
d="M464 448c4.09961 0 7.7998 -2 10.0996 -5.40039l99.9004 -147.199c2.90039 -4.40039 2.59961 -10.1006 -0.700195 -14.2002l-276 -340.8c-4.7998 -5.90039 -13.7998 -5.90039 -18.5996 0l-276 340.8c-3.2998 4 -3.60059 9.7998 -0.700195 14.2002l100 147.199
|
||||
c2.2002 3.40039 6 5.40039 10 5.40039h352zM444.7 400h-56.7998l51.6992 -96h68.4004zM242.6 400l-51.5996 -96h194l-51.7002 96h-90.7002zM131.3 400l-63.2998 -96h68.4004l51.6992 96h-56.7998zM88.2998 256l119.7 -160l-68.2998 160h-51.4004zM191.2 256l96.7998 -243.3
|
||||
l96.7998 243.3h-193.6zM368 96l119.6 160h-51.3994z" />
|
||||
<glyph glyph-name="money-bill-alt" unicode="" horiz-adv-x="640"
|
||||
d="M320 304c53.0195 0 96 -50.1396 96 -112c0 -61.8701 -43 -112 -96 -112c-53.0195 0 -96 50.1504 -96 112c0 61.8604 42.9805 112 96 112zM360 136v16c0 4.41992 -3.58008 8 -8 8h-16v88c0 4.41992 -3.58008 8 -8 8h-13.5801
|
||||
c-4.91113 0 -9.50586 -1.49316 -13.3096 -4.03027l-15.3301 -10.2197c-2.15332 -1.43262 -3.55957 -3.88379 -3.55957 -6.66113c0 -1.6377 0.493164 -3.16113 1.33887 -4.42871l8.88086 -13.3105c1.43164 -2.15234 3.88379 -3.55957 6.66113 -3.55957
|
||||
c1.6377 0 3.16016 0.494141 4.42871 1.33984l0.469727 0.310547v-55.4404h-16c-4.41992 0 -8 -3.58008 -8 -8v-16c0 -4.41992 3.58008 -8 8 -8h64c4.41992 0 8 3.58008 8 8zM608 384c17.6699 0 32 -14.3301 32 -32v-320c0 -17.6699 -14.3301 -32 -32 -32h-576
|
||||
c-17.6699 0 -32 14.3301 -32 32v320c0 17.6699 14.3301 32 32 32h576zM592 112v160c-35.3496 0 -64 28.6504 -64 64h-416c0 -35.3496 -28.6504 -64 -64 -64v-160c35.3496 0 64 -28.6504 64 -64h416c0 35.3496 28.6504 64 64 64z" />
|
||||
<glyph glyph-name="window-close" unicode=""
|
||||
d="M464 416c26.5 0 48 -21.5 48 -48v-352c0 -26.5 -21.5 -48 -48 -48h-416c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h416zM464 22v340c0 3.2998 -2.7002 6 -6 6h-404c-3.2998 0 -6 -2.7002 -6 -6v-340c0 -3.2998 2.7002 -6 6 -6h404c3.2998 0 6 2.7002 6 6z
|
||||
M356.5 253.4l-61.4004 -61.4004l61.4004 -61.4004c4.59961 -4.59961 4.59961 -12.0996 0 -16.7998l-22.2998 -22.2998c-4.60059 -4.59961 -12.1006 -4.59961 -16.7998 0l-61.4004 61.4004l-61.4004 -61.4004c-4.59961 -4.59961 -12.0996 -4.59961 -16.7998 0
|
||||
l-22.2998 22.2998c-4.59961 4.60059 -4.59961 12.1006 0 16.7998l61.4004 61.4004l-61.4004 61.4004c-4.59961 4.59961 -4.59961 12.0996 0 16.7998l22.2998 22.2998c4.60059 4.59961 12.1006 4.59961 16.7998 0l61.4004 -61.4004l61.4004 61.4004
|
||||
c4.59961 4.59961 12.0996 4.59961 16.7998 0l22.2998 -22.2998c4.7002 -4.60059 4.7002 -12.1006 0 -16.7998z" />
|
||||
<glyph glyph-name="comment-dots" unicode=""
|
||||
d="M144 240c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32zM256 240c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32zM368 240c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32
|
||||
s-32 14.2998 -32 32s14.2998 32 32 32zM256 416c141.4 0 256 -93.0996 256 -208s-114.6 -208 -256 -208c-32.7998 0 -64 5.2002 -92.9004 14.2998c-29.0996 -20.5996 -77.5996 -46.2998 -139.1 -46.2998c-9.59961 0 -18.2998 5.7002 -22.0996 14.5
|
||||
c-3.80078 8.7998 -2 19 4.59961 26c0.5 0.400391 31.5 33.7998 46.4004 73.2002c-33 35.0996 -52.9004 78.7002 -52.9004 126.3c0 114.9 114.6 208 256 208zM256 48c114.7 0 208 71.7998 208 160s-93.2998 160 -208 160s-208 -71.7998 -208 -160
|
||||
c0 -42.2002 21.7002 -74.0996 39.7998 -93.4004l20.6006 -21.7998l-10.6006 -28.0996c-5.5 -14.5 -12.5996 -28.1006 -19.8994 -40.2002c23.5996 7.59961 43.1992 18.9004 57.5 29l19.5 13.7998l22.6992 -7.2002c25.3008 -8 51.7002 -12.0996 78.4004 -12.0996z" />
|
||||
<glyph glyph-name="smile-wink" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM365.8 138.4c10.2002 -8.5 11.6006 -23.6006 3.10059 -33.8008
|
||||
c-30 -36 -74.1006 -56.5996 -120.9 -56.5996s-90.9004 20.5996 -120.9 56.5996c-8.39941 10.2002 -7.09961 25.3008 3.10059 33.8008c10.0996 8.39941 25.2998 7.09961 33.7998 -3.10059c20.7998 -25.0996 51.5 -39.3994 84 -39.3994s63.2002 14.3994 84 39.3994
|
||||
c8.5 10.2002 23.5996 11.6006 33.7998 3.10059zM168 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM328 268c25.7002 0 55.9004 -16.9004 59.7002 -42.0996c1.7998 -11.1006 -11.2998 -18.2002 -19.7998 -10.8008l-9.5 8.5
|
||||
c-14.8008 13.2002 -46.2002 13.2002 -61 0l-9.5 -8.5c-8.30078 -7.39941 -21.5 -0.399414 -19.8008 10.8008c4 25.1992 34.2002 42.0996 59.9004 42.0996z" />
|
||||
<glyph glyph-name="angry" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM248 136c33.5996 0 65.2002 -14.7998 86.7998 -40.5996
|
||||
c8.40039 -10.2002 7.10059 -25.3008 -3.09961 -33.8008c-10.6006 -8.89941 -25.7002 -6.69922 -33.7998 3c-24.8008 29.7002 -75 29.7002 -99.8008 0c-8.5 -10.1992 -23.5996 -11.5 -33.7998 -3s-11.5996 23.6006 -3.09961 33.8008
|
||||
c21.5996 25.7998 53.2002 40.5996 86.7998 40.5996zM200 208c0 -17.7002 -14.2998 -32.0996 -32 -32.0996s-32 14.2998 -32 32c0 6.19922 2.2002 11.6992 5.2998 16.5996l-28.2002 8.5c-12.6992 3.7998 -19.8994 17.2002 -16.0996 29.9004
|
||||
c3.7998 12.6992 17.0996 20 29.9004 16.0996l80 -24c12.6992 -3.7998 19.8994 -17.2002 16.0996 -29.9004c-3.09961 -10.3994 -12.7002 -17.0996 -23 -17.0996zM399 262.9c3.7998 -12.7002 -3.40039 -26.1006 -16.0996 -29.8008l-28.2002 -8.5
|
||||
c3.09961 -4.89941 5.2998 -10.3994 5.2998 -16.5996c0 -17.7002 -14.2998 -32 -32 -32s-32 14.2998 -32 32c-10.2998 0 -19.9004 6.7002 -23 17.0996c-3.7998 12.7002 3.40039 26.1006 16.0996 29.9004l80 24c12.8008 3.7998 26.1006 -3.40039 29.9004 -16.0996z" />
|
||||
<glyph glyph-name="dizzy" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM214.2 209.9
|
||||
c-7.90039 -7.90039 -20.5 -7.90039 -28.4004 -0.200195l-17.7998 17.7998l-17.7998 -17.7998c-7.7998 -7.7998 -20.5 -7.7998 -28.2998 0c-7.80078 7.7998 -7.80078 20.5 0 28.2998l17.8994 17.9004l-17.8994 17.8994c-7.80078 7.7998 -7.80078 20.5 0 28.2998
|
||||
c7.7998 7.80078 20.5 7.80078 28.2998 0l17.7998 -17.7998l17.9004 17.9004c7.7998 7.7998 20.5 7.7998 28.2998 0s7.7998 -20.5 0 -28.2998l-17.9004 -17.9004l17.9004 -17.7998c7.7998 -7.7998 7.7998 -20.5 0 -28.2998zM374.2 302.1
|
||||
c7.7002 -7.7998 7.7002 -20.3994 0 -28.1992l-17.9004 -17.9004l17.7998 -18c7.80078 -7.7998 7.80078 -20.5 0 -28.2998c-7.7998 -7.7998 -20.5 -7.7998 -28.2998 0l-17.7998 17.7998l-17.7998 -17.7998c-7.7998 -7.7998 -20.5 -7.7998 -28.2998 0
|
||||
c-7.80078 7.7998 -7.80078 20.5 0 28.2998l17.8994 17.9004l-17.8994 17.8994c-7.80078 7.7998 -7.80078 20.5 0 28.2998c7.7998 7.80078 20.5 7.80078 28.2998 0l17.7998 -17.7998l17.9004 17.7998c7.7998 7.80078 20.5 7.80078 28.2998 0zM248 176
|
||||
c35.2998 0 64 -28.7002 64 -64s-28.7002 -64 -64 -64s-64 28.7002 -64 64s28.7002 64 64 64z" />
|
||||
<glyph glyph-name="flushed" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM344 304c44.2002 0 80 -35.7998 80 -80s-35.7998 -80 -80 -80
|
||||
s-80 35.7998 -80 80s35.7998 80 80 80zM344 176c26.5 0 48 21.5 48 48s-21.5 48 -48 48s-48 -21.5 -48 -48s21.5 -48 48 -48zM344 248c13.2998 0 24 -10.7002 24 -24s-10.7002 -24 -24 -24s-24 10.7002 -24 24s10.7002 24 24 24zM232 224c0 -44.2002 -35.7998 -80 -80 -80
|
||||
s-80 35.7998 -80 80s35.7998 80 80 80s80 -35.7998 80 -80zM152 176c26.5 0 48 21.5 48 48s-21.5 48 -48 48s-48 -21.5 -48 -48s21.5 -48 48 -48zM152 248c13.2998 0 24 -10.7002 24 -24s-10.7002 -24 -24 -24s-24 10.7002 -24 24s10.7002 24 24 24zM312 104
|
||||
c13.2002 0 24 -10.7998 24 -24s-10.7998 -24 -24 -24h-128c-13.2002 0 -24 10.7998 -24 24s10.7998 24 24 24h128z" />
|
||||
<glyph glyph-name="frown-open" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM200 240c0 -17.7002 -14.2998 -32 -32 -32s-32 14.2998 -32 32
|
||||
s14.2998 32 32 32s32 -14.2998 32 -32zM328 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32zM248 160c35.5996 0 88.7998 -21.2998 95.7998 -61.2002c2 -11.7998 -9.09961 -21.5996 -20.5 -18.0996
|
||||
c-31.2002 9.59961 -59.3994 15.2998 -75.2998 15.2998s-44.0996 -5.7002 -75.2998 -15.2998c-11.5 -3.40039 -22.5 6.2998 -20.5 18.0996c7 39.9004 60.2002 61.2002 95.7998 61.2002z" />
|
||||
<glyph glyph-name="grimace" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM168 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32
|
||||
s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM328 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM344 192c26.5 0 48 -21.5 48 -48v-32c0 -26.5 -21.5 -48 -48 -48h-192c-26.5 0 -48 21.5 -48 48v32c0 26.5 21.5 48 48 48
|
||||
h192zM176 96v24h-40v-8c0 -8.7998 7.2002 -16 16 -16h24zM176 136v24h-24c-8.7998 0 -16 -7.2002 -16 -16v-8h40zM240 96v24h-48v-24h48zM240 136v24h-48v-24h48zM304 96v24h-48v-24h48zM304 136v24h-48v-24h48zM360 112v8h-40v-24h24c8.7998 0 16 7.2002 16 16zM360 136v8
|
||||
c0 8.7998 -7.2002 16 -16 16h-24v-24h40z" />
|
||||
<glyph glyph-name="grin" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM353.6 143.4c10 3.09961 19.3008 -5.5 17.7002 -15.3008
|
||||
c-8 -47.0996 -71.2998 -80 -123.3 -80s-115.4 32.9004 -123.3 80c-1.7002 9.90039 7.7998 18.4004 17.7002 15.3008c26 -8.30078 64.3994 -13.1006 105.6 -13.1006s79.7002 4.7998 105.6 13.1006zM168 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32
|
||||
s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM328 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32s32 -14.2998 32 -32s-14.2998 -32 -32 -32z" />
|
||||
<glyph glyph-name="grin-alt" unicode="" horiz-adv-x="496"
|
||||
d="M200.3 200c-7.5 -11.4004 -24.5996 -12 -32.7002 0c-12.3994 18.7002 -15.1992 37.2998 -15.6992 56c0.599609 18.7002 3.2998 37.2998 15.6992 56c7.60059 11.4004 24.7002 12 32.7002 0c12.4004 -18.7002 15.2002 -37.2998 15.7002 -56
|
||||
c-0.599609 -18.7002 -3.2998 -37.2998 -15.7002 -56zM328.3 200c-7.5 -11.4004 -24.5996 -12 -32.7002 0c-12.3994 18.7002 -15.1992 37.2998 -15.6992 56c0.599609 18.7002 3.2998 37.2998 15.6992 56c7.60059 11.4004 24.7002 12 32.7002 0
|
||||
c12.4004 -18.7002 15.2002 -37.2998 15.7002 -56c-0.599609 -18.7002 -3.2998 -37.2998 -15.7002 -56zM248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200
|
||||
s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM353.6 143.4c10 3.09961 19.3008 -5.5 17.7002 -15.3008c-8 -47.0996 -71.2998 -80 -123.3 -80s-115.4 32.8008 -123.3 80c-1.7002 10 7.7998 18.4004 17.7002 15.3008c26 -8.30078 64.3994 -13.1006 105.6 -13.1006
|
||||
s79.7002 4.7998 105.6 13.1006z" />
|
||||
<glyph glyph-name="grin-beam" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM353.6 143.4c10 3.09961 19.3008 -5.5 17.7002 -15.3008
|
||||
c-8 -47.0996 -71.2998 -80 -123.3 -80s-115.4 32.9004 -123.3 80c-1.7002 10 7.89941 18.4004 17.7002 15.3008c26 -8.30078 64.3994 -13.1006 105.6 -13.1006s79.7002 4.7998 105.6 13.1006zM117.7 216.3c-3.60059 1.10059 -6 4.60059 -5.7002 8.2998
|
||||
c3.2998 42.1006 32.2002 71.4004 56 71.4004s52.7002 -29.2998 56 -71.4004c0.299805 -3.7998 -2.09961 -7.19922 -5.7002 -8.2998c-3.09961 -1 -7.2002 0 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996s-23.7998 -7.89941 -31.5 -21.5996
|
||||
l-9.5 -17c-1.90039 -3.2002 -5.7998 -4.7998 -9.2998 -3.7002zM277.7 216.3c-3.60059 1.10059 -6 4.60059 -5.7002 8.2998c3.2998 42.1006 32.2002 71.4004 56 71.4004s52.7002 -29.2998 56 -71.4004c0.299805 -3.7998 -2.09961 -7.19922 -5.7002 -8.2998
|
||||
c-3.09961 -1 -7.2002 0 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996s-23.7998 -7.89941 -31.5 -21.5996l-9.5 -17c-1.90039 -3.2002 -5.7998 -4.7998 -9.2998 -3.7002z" />
|
||||
<glyph glyph-name="grin-beam-sweat" unicode="" horiz-adv-x="496"
|
||||
d="M440 288c-29.5 0 -53.2998 26.2998 -53.2998 58.7002c0 25 31.7002 75.5 46.2002 97.2998c3.5 5.2998 10.5996 5.2998 14.1992 0c14.5 -21.7998 46.2002 -72.2998 46.2002 -97.2998c0 -32.4004 -23.7998 -58.7002 -53.2998 -58.7002zM248 48
|
||||
c-51.9004 0 -115.3 32.9004 -123.3 80c-1.7002 10 7.89941 18.4004 17.7002 15.2998c26 -8.2998 64.3994 -13.0996 105.6 -13.0996s79.7002 4.7998 105.6 13.0996c10 3.2002 19.4004 -5.39941 17.7002 -15.2998c-8 -47.0996 -71.3994 -80 -123.3 -80zM378.3 216.3
|
||||
c-3.09961 -0.899414 -7.2002 0.100586 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996s-23.7998 -7.89941 -31.5 -21.5996l-9.5 -17c-1.90039 -3.2002 -5.7998 -4.7998 -9.2998 -3.7002c-3.60059 1.10059 -6 4.60059 -5.7002 8.2998
|
||||
c3.2998 42.1006 32.2002 71.4004 56 71.4004s52.7002 -29.2998 56 -71.4004c0.299805 -3.7998 -2.09961 -7.19922 -5.7002 -8.2998zM483.6 269.2c8 -24.2998 12.4004 -50.2002 12.4004 -77.2002c0 -137 -111 -248 -248 -248s-248 111 -248 248s111 248 248 248
|
||||
c45.7002 0 88.4004 -12.5996 125.2 -34.2002c-10.9004 -21.5996 -15.5 -36.2002 -17.2002 -45.7002c-31.2002 20.1006 -68.2002 31.9004 -108 31.9004c-110.3 0 -200 -89.7002 -200 -200s89.7002 -200 200 -200s200 89.7002 200 200
|
||||
c0 22.5 -3.90039 44.0996 -10.7998 64.2998c0.399414 0 21.7998 -2.7998 46.3994 12.9004zM168 258.6c-12.2998 0 -23.7998 -7.7998 -31.5 -21.5996l-9.5 -17c-1.90039 -3.2002 -5.7998 -4.7998 -9.2998 -3.7002c-3.60059 1.10059 -6 4.60059 -5.7002 8.2998
|
||||
c3.2998 42.1006 32.2002 71.4004 56 71.4004s52.7002 -29.2998 56 -71.4004c0.299805 -3.7998 -2.09961 -7.19922 -5.7002 -8.2998c-3.09961 -1 -7.2002 0 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996z" />
|
||||
<glyph glyph-name="grin-hearts" unicode="" horiz-adv-x="496"
|
||||
d="M353.6 143.4c10 3.09961 19.3008 -5.5 17.7002 -15.3008c-8 -47.0996 -71.2998 -80 -123.3 -80s-115.4 32.8008 -123.3 80c-1.7002 10 7.89941 18.4004 17.7002 15.3008c26 -8.30078 64.3994 -13.1006 105.6 -13.1006s79.7002 4.7998 105.6 13.1006zM200.8 192.3
|
||||
l-70.2002 18.1006c-20.3994 5.2998 -31.8994 27 -24.1992 47.1992c6.69922 17.7002 26.6992 26.7002 44.8994 22l7.10059 -1.89941l2 7.09961c5.09961 18.1006 22.8994 30.9004 41.5 27.9004c21.3994 -3.40039 34.3994 -24.2002 28.7998 -44.5l-19.4004 -69.9004
|
||||
c-1.2998 -4.5 -6 -7.2002 -10.5 -6zM389.6 257.6c7.7002 -20.1992 -3.7998 -41.7998 -24.1992 -47.0996l-70.2002 -18.2002c-4.60059 -1.2002 -9.2998 1.5 -10.5 6l-19.4004 69.9004c-5.59961 20.2998 7.40039 41.0996 28.7998 44.5c18.7002 3 36.5 -9.7998 41.5 -27.9004
|
||||
l2 -7.09961l7.10059 1.89941c18.2002 4.7002 38.2002 -4.39941 44.8994 -22zM248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200
|
||||
s89.7002 -200 200 -200z" />
|
||||
<glyph glyph-name="grin-squint" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM353.6 143.4c10 3.09961 19.3008 -5.5 17.7002 -15.3008
|
||||
c-8 -47.0996 -71.2998 -80 -123.3 -80s-115.4 32.9004 -123.3 80c-1.7002 9.90039 7.7998 18.4004 17.7002 15.3008c26 -8.30078 64.3994 -13.1006 105.6 -13.1006s79.7002 4.7998 105.6 13.1006zM118.9 184.2c-3.80078 4.39941 -3.90039 11 -0.100586 15.5l33.6006 40.2998
|
||||
l-33.6006 40.2998c-3.7002 4.5 -3.7002 11 0.100586 15.5c3.89941 4.40039 10.1992 5.5 15.2998 2.5l80 -48c3.59961 -2.2002 5.7998 -6.09961 5.7998 -10.2998s-2.2002 -8.09961 -5.7998 -10.2998l-80 -48c-5.40039 -3.2002 -11.7002 -1.7002 -15.2998 2.5zM361.8 181.7
|
||||
l-80 48c-3.59961 2.2002 -5.7998 6.09961 -5.7998 10.2998s2.2002 8.09961 5.7998 10.2998l80 48c5.10059 2.90039 11.5 1.90039 15.2998 -2.5c3.80078 -4.5 3.90039 -11 0.100586 -15.5l-33.6006 -40.2998l33.6006 -40.2998c3.7002 -4.5 3.7002 -11 -0.100586 -15.5
|
||||
c-3.59961 -4.2002 -9.89941 -5.7002 -15.2998 -2.5z" />
|
||||
<glyph glyph-name="grin-squint-tears" unicode=""
|
||||
d="M117.1 63.9004c6.30078 0.899414 11.7002 -4.5 10.9004 -10.9004c-3.7002 -25.7998 -13.7002 -84 -30.5996 -100.9c-22 -21.8994 -57.9004 -21.5 -80.3008 0.900391c-22.3994 22.4004 -22.7998 58.4004 -0.899414 80.2998
|
||||
c16.8994 16.9004 75.0996 26.9004 100.899 30.6006zM75.9004 105.6c-19.6006 -3.89941 -35.1006 -8.09961 -47.3008 -12.1992c-39.2998 90.5996 -22.0996 199.899 52 274c48.5 48.3994 111.9 72.5996 175.4 72.5996c38.9004 0 77.7998 -9.2002 113.2 -27.4004
|
||||
c-4 -12.1992 -8.2002 -28 -12 -48.2998c-30.4004 17.9004 -65 27.7002 -101.2 27.7002c-53.4004 0 -103.6 -20.7998 -141.4 -58.5996c-61.5996 -61.5 -74.2998 -153.4 -38.6992 -227.801zM428.2 293.2c20.2998 3.89941 36.2002 8 48.5 12
|
||||
c47.8994 -93.2002 32.8994 -210.5 -45.2002 -288.601c-48.5 -48.3994 -111.9 -72.5996 -175.4 -72.5996c-33.6992 0 -67.2998 7 -98.6992 20.5996c4.19922 12.2002 8.2998 27.7002 12.1992 47.2002c26.6006 -12.7998 55.9004 -19.7998 86.4004 -19.7998
|
||||
c53.4004 0 103.6 20.7998 141.4 58.5996c65.6992 65.7002 75.7998 166 30.7998 242.601zM394.9 320.1c-6.30078 -0.899414 -11.7002 4.5 -10.9004 10.9004c3.7002 25.7998 13.7002 84 30.5996 100.9c22 21.8994 57.9004 21.5 80.3008 -0.900391
|
||||
c22.3994 -22.4004 22.7998 -58.4004 0.899414 -80.2998c-16.8994 -16.9004 -75.0996 -26.9004 -100.899 -30.6006zM207.9 211.8c3 -3 4.19922 -7.2998 3.19922 -11.5l-22.5996 -90.5c-1.40039 -5.39941 -6.2002 -9.09961 -11.7002 -9.09961h-0.899414
|
||||
c-5.80078 0.5 -10.5 5.09961 -11 10.8994l-4.80078 52.3008l-52.2998 4.7998c-5.7998 0.5 -10.3994 5.2002 -10.8994 11c-0.400391 5.89941 3.39941 11.2002 9.09961 12.5996l90.5 22.7002c4.2002 1 8.40039 -0.200195 11.4004 -3.2002zM247.6 236.9
|
||||
c-0.0996094 0 -6.39941 -1.80078 -11.3994 3.19922c-3 3 -4.2002 7.30078 -3.2002 11.4004l22.5996 90.5c1.40039 5.7002 7 9.2002 12.6006 9.09961c5.7998 -0.5 10.5 -5.09961 11 -10.8994l4.7998 -52.2998l52.2998 -4.80078c5.7998 -0.5 10.4004 -5.19922 10.9004 -11
|
||||
c0.399414 -5.89941 -3.40039 -11.1992 -9.10059 -12.5996zM299.6 148.4c29.1006 29.0996 53 59.5996 65.3008 83.7998c4.89941 9.2998 17.5996 9.89941 23.3994 1.7002c27.7002 -38.9004 6.10059 -106.9 -30.5996 -143.7s-104.8 -58.2998 -143.7 -30.6006
|
||||
c-8.2998 5.90039 -7.5 18.6006 1.7002 23.4004c24.2002 12.5 54.7998 36.2998 83.8994 65.4004z" />
|
||||
<glyph glyph-name="grin-stars" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM353.6 143.4c10 3.09961 19.3008 -5.5 17.7002 -15.3008
|
||||
c-8 -47.0996 -71.2998 -80 -123.3 -80s-115.4 32.8008 -123.3 80c-1.7002 10 7.89941 18.4004 17.7002 15.3008c26 -8.30078 64.3994 -13.1006 105.6 -13.1006s79.7002 4.7998 105.6 13.1006zM125.7 200.9l6.09961 34.8994l-25.3994 24.6006
|
||||
c-4.60059 4.59961 -1.90039 12.2998 4.2998 13.1992l34.8994 5l15.5 31.6006c2.90039 5.7998 11 5.7998 13.9004 0l15.5 -31.6006l34.9004 -5c6.19922 -1 8.7998 -8.69922 4.2998 -13.1992l-25.4004 -24.6006l6 -34.8994c1 -6.2002 -5.39941 -11 -11 -7.90039
|
||||
l-31.2998 16.2998l-31.2998 -16.2998c-5.60059 -3.09961 -12 1.7002 -11 7.90039zM385.4 273.6c6.19922 -1 8.89941 -8.59961 4.39941 -13.1992l-25.3994 -24.6006l6 -34.8994c1 -6.2002 -5.40039 -11 -11 -7.90039l-31.3008 16.2998l-31.2998 -16.2998
|
||||
c-5.59961 -3.09961 -12 1.7002 -11 7.90039l6 34.8994l-25.3994 24.6006c-4.60059 4.59961 -1.90039 12.2998 4.2998 13.1992l34.8994 5l15.5 31.6006c2.90039 5.7998 11 5.7998 13.9004 0l15.5 -31.6006z" />
|
||||
<glyph glyph-name="grin-tears" unicode="" horiz-adv-x="640"
|
||||
d="M117.1 191.9c6.30078 0.899414 11.7002 -4.5 10.9004 -10.9004c-3.7002 -25.7998 -13.7002 -84 -30.5996 -100.9c-22 -21.8994 -57.9004 -21.5 -80.3008 0.900391c-22.3994 22.4004 -22.7998 58.4004 -0.899414 80.2998c16.8994 16.9004 75.0996 26.9004 100.899 30.6006
|
||||
zM623.8 161.3c21.9004 -21.8994 21.5 -57.8994 -0.799805 -80.2002c-22.4004 -22.3994 -58.4004 -22.7998 -80.2998 -0.899414c-16.9004 16.8994 -26.9004 75.0996 -30.6006 100.899c-0.899414 6.30078 4.5 11.7002 10.8008 10.8008
|
||||
c25.7998 -3.7002 84 -13.7002 100.899 -30.6006zM497.2 99.5996c12.3994 -37.2998 25.0996 -43.7998 28.2998 -46.5c-44.5996 -65.7998 -120 -109.1 -205.5 -109.1s-160.9 43.2998 -205.5 109.1c3.09961 2.60059 15.7998 9.10059 28.2998 46.5
|
||||
c33.4004 -63.8994 100.3 -107.6 177.2 -107.6s143.8 43.7002 177.2 107.6zM122.7 223.5c-2.40039 0.299805 -5 2.5 -49.5 -6.90039c12.3994 125.4 118.1 223.4 246.8 223.4s234.4 -98 246.8 -223.5c-44.2998 9.40039 -47.3994 7.2002 -49.5 7
|
||||
c-15.2002 95.2998 -97.7998 168.5 -197.3 168.5s-182.1 -73.2002 -197.3 -168.5zM320 48c-51.9004 0 -115.3 32.9004 -123.3 80c-1.7002 10 7.89941 18.4004 17.7002 15.2998c26 -8.2998 64.3994 -13.0996 105.6 -13.0996s79.7002 4.7998 105.6 13.0996
|
||||
c10 3.2002 19.4004 -5.39941 17.7002 -15.2998c-8 -47.0996 -71.3994 -80 -123.3 -80zM450.3 216.3c-3.09961 -0.899414 -7.2002 0.100586 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996s-23.7998 -7.89941 -31.5 -21.5996l-9.5 -17
|
||||
c-1.90039 -3.2002 -5.7998 -4.7998 -9.2998 -3.7002c-3.60059 1.10059 -6 4.60059 -5.7002 8.2998c3.2998 42.1006 32.2002 71.4004 56 71.4004s52.7002 -29.2998 56 -71.4004c0.299805 -3.7998 -2.09961 -7.19922 -5.7002 -8.2998zM240 258.6
|
||||
c-12.2998 0 -23.7998 -7.7998 -31.5 -21.5996l-9.5 -17c-1.90039 -3.2002 -5.7998 -4.7998 -9.2998 -3.7002c-3.60059 1.10059 -6 4.60059 -5.7002 8.2998c3.2998 42.1006 32.2002 71.4004 56 71.4004s52.7002 -29.2998 56 -71.4004
|
||||
c0.299805 -3.7998 -2.09961 -7.19922 -5.7002 -8.2998c-3.09961 -1 -7.2002 0 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996z" />
|
||||
<glyph glyph-name="grin-tongue" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM312 40h0.0996094v43.7998l-17.6992 8.7998c-15.1006 7.60059 -31.5 -1.69922 -34.9004 -16.5l-2.7998 -12.0996c-2.10059 -9.2002 -15.2002 -9.2002 -17.2998 0
|
||||
l-2.80078 12.0996c-3.39941 14.8008 -19.8994 24 -34.8994 16.5l-17.7002 -8.7998v-42.7998c0 -35.2002 28 -64.5 63.0996 -65c35.8008 -0.5 64.9004 28.4004 64.9004 64zM340.2 14.7002c64 33.3994 107.8 100.3 107.8 177.3c0 110.3 -89.7002 200 -200 200
|
||||
s-200 -89.7002 -200 -200c0 -77 43.7998 -143.9 107.8 -177.3c-2.2002 8.09961 -3.7998 16.5 -3.7998 25.2998v43.5c-14.2002 12.4004 -24.4004 27.5 -27.2998 44.5c-1.7002 10 7.7998 18.4004 17.7002 15.2998c26 -8.2998 64.3994 -13.0996 105.6 -13.0996
|
||||
s79.7002 4.7998 105.6 13.0996c10 3.2002 19.4004 -5.39941 17.7002 -15.2998c-2.89941 -17 -13.0996 -32.0996 -27.2998 -44.5v-43.5c0 -8.7998 -1.59961 -17.2002 -3.7998 -25.2998zM168 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32
|
||||
s14.2998 32 32 32zM328 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32z" />
|
||||
<glyph glyph-name="grin-tongue-squint" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM312 40h0.0996094v43.7998l-17.6992 8.7998c-15.1006 7.60059 -31.5 -1.69922 -34.9004 -16.5l-2.7998 -12.0996c-2.10059 -9.2002 -15.2002 -9.2002 -17.2998 0
|
||||
l-2.80078 12.0996c-3.39941 14.8008 -19.8994 24 -34.8994 16.5l-17.7002 -8.7998v-42.7998c0 -35.2002 28 -64.5 63.0996 -65c35.8008 -0.5 64.9004 28.4004 64.9004 64zM340.2 14.7002c64 33.3994 107.8 100.3 107.8 177.3c0 110.3 -89.7002 200 -200 200
|
||||
s-200 -89.7002 -200 -200c0 -77 43.7998 -143.9 107.8 -177.3c-2.2002 8.09961 -3.7998 16.5 -3.7998 25.2998v43.5c-14.2002 12.4004 -24.4004 27.5 -27.2998 44.5c-1.7002 10 7.7998 18.4004 17.7002 15.2998c26 -8.2998 64.3994 -13.0996 105.6 -13.0996
|
||||
s79.7002 4.7998 105.6 13.0996c10 3.2002 19.4004 -5.39941 17.7002 -15.2998c-2.89941 -17 -13.0996 -32.0996 -27.2998 -44.5v-43.5c0 -8.7998 -1.59961 -17.2002 -3.7998 -25.2998zM377.1 295.8c3.80078 -4.39941 3.90039 -11 0.100586 -15.5l-33.6006 -40.2998
|
||||
l33.6006 -40.2998c3.7002 -4.5 3.7002 -11 -0.100586 -15.5c-3.59961 -4.2002 -9.89941 -5.7002 -15.2998 -2.5l-80 48c-3.59961 2.2002 -5.7998 6.09961 -5.7998 10.2998s2.2002 8.09961 5.7998 10.2998l80 48c5 3 11.5 1.90039 15.2998 -2.5zM214.2 250.3
|
||||
c3.59961 -2.2002 5.7998 -6.09961 5.7998 -10.2998s-2.2002 -8.09961 -5.7998 -10.2998l-80 -48c-5.40039 -3.2002 -11.7002 -1.7002 -15.2998 2.5c-3.80078 4.5 -3.90039 11 -0.100586 15.5l33.6006 40.2998l-33.6006 40.2998c-3.7002 4.5 -3.7002 11 0.100586 15.5
|
||||
c3.89941 4.5 10.2998 5.5 15.2998 2.5z" />
|
||||
<glyph glyph-name="grin-tongue-wink" unicode="" horiz-adv-x="496"
|
||||
d="M152 268c25.7002 0 55.9004 -16.9004 59.7998 -42.0996c0.799805 -5 -1.7002 -10 -6.09961 -12.4004c-5.7002 -3.09961 -11.2002 -0.599609 -13.7002 1.59961l-9.5 8.5c-14.7998 13.2002 -46.2002 13.2002 -61 0l-9.5 -8.5
|
||||
c-3.7998 -3.39941 -9.2998 -4 -13.7002 -1.59961c-4.39941 2.40039 -6.89941 7.40039 -6.09961 12.4004c3.89941 25.1992 34.0996 42.0996 59.7998 42.0996zM328 320c44.2002 0 80 -35.7998 80 -80s-35.7998 -80 -80 -80s-80 35.7998 -80 80s35.7998 80 80 80zM328 192
|
||||
c26.5 0 48 21.5 48 48s-21.5 48 -48 48s-48 -21.5 -48 -48s21.5 -48 48 -48zM328 264c13.2998 0 24 -10.7002 24 -24s-10.7002 -24 -24 -24s-24 10.7002 -24 24s10.7002 24 24 24zM248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248z
|
||||
M312 40h0.0996094v43.7998l-17.6992 8.7998c-15.1006 7.60059 -31.5 -1.69922 -34.9004 -16.5l-2.7998 -12.0996c-2.10059 -9.2002 -15.2002 -9.2002 -17.2998 0l-2.80078 12.0996c-3.39941 14.8008 -19.8994 24 -34.8994 16.5l-17.7002 -8.7998v-42.7998
|
||||
c0 -35.2002 28 -64.5 63.0996 -65c35.8008 -0.5 64.9004 28.4004 64.9004 64zM340.2 14.7002c64 33.3994 107.8 100.3 107.8 177.3c0 110.3 -89.7002 200 -200 200s-200 -89.7002 -200 -200c0 -77 43.7998 -143.9 107.8 -177.3
|
||||
c-2.2002 8.09961 -3.7998 16.5 -3.7998 25.2998v43.5c-14.2002 12.4004 -24.4004 27.5 -27.2998 44.5c-1.7002 10 7.7998 18.4004 17.7002 15.2998c26 -8.2998 64.3994 -13.0996 105.6 -13.0996s79.7002 4.7998 105.6 13.0996c10 3.2002 19.4004 -5.39941 17.7002 -15.2998
|
||||
c-2.89941 -17 -13.0996 -32.0996 -27.2998 -44.5v-43.5c0 -8.7998 -1.59961 -17.2002 -3.7998 -25.2998z" />
|
||||
<glyph glyph-name="grin-wink" unicode="" horiz-adv-x="496"
|
||||
d="M328 268c25.6904 0 55.8799 -16.9199 59.8701 -42.1201c1.72949 -11.0898 -11.3506 -18.2695 -19.8301 -10.8398l-9.5498 8.47949c-14.8105 13.1904 -46.1602 13.1904 -60.9707 0l-9.5498 -8.47949c-8.33008 -7.40039 -21.5801 -0.379883 -19.8301 10.8398
|
||||
c3.98047 25.2002 34.1699 42.1201 59.8604 42.1201zM168 208c-17.6699 0 -32 14.3301 -32 32s14.3301 32 32 32s32 -14.3301 32 -32s-14.3301 -32 -32 -32zM353.55 143.36c10.04 3.13965 19.3906 -5.4502 17.71 -15.3408
|
||||
c-7.92969 -47.1494 -71.3193 -80.0195 -123.26 -80.0195s-115.33 32.8701 -123.26 80.0195c-1.69043 9.9707 7.76953 18.4707 17.71 15.3408c25.9297 -8.31055 64.3994 -13.0605 105.55 -13.0605s79.6201 4.75977 105.55 13.0605zM248 440c136.97 0 248 -111.03 248 -248
|
||||
s-111.03 -248 -248 -248s-248 111.03 -248 248s111.03 248 248 248zM248 -8c110.28 0 200 89.7197 200 200s-89.7197 200 -200 200s-200 -89.7197 -200 -200s89.7197 -200 200 -200z" />
|
||||
<glyph glyph-name="kiss" unicode="" horiz-adv-x="496"
|
||||
d="M168 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32zM304 140c0 -13 -13.4004 -27.2998 -35.0996 -36.4004c21.7998 -8.69922 35.1992 -23 35.1992 -36c0 -19.1992 -28.6992 -41.5 -71.5 -44h-0.5
|
||||
c-3.69922 0 -7 2.60059 -7.7998 6.2002c-0.899414 3.7998 1.10059 7.7002 4.7002 9.2002l17 7.2002c12.9004 5.5 20.7002 13.5 20.7002 21.5s-7.7998 16 -20.7998 21.5l-16.9004 7.2002c-6 2.59961 -5.7002 12.3994 0 14.7998l17 7.2002
|
||||
c12.9004 5.5 20.7002 13.5 20.7002 21.5s-7.7998 16 -20.7998 21.5l-16.9004 7.19922c-3.59961 1.5 -5.59961 5.40039 -4.7002 9.2002c0.799805 3.7998 4.40039 6.60059 8.2002 6.2002c42.7002 -2.5 71.5 -24.7998 71.5 -44zM248 440c137 0 248 -111 248 -248
|
||||
s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM328 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32z
|
||||
" />
|
||||
<glyph glyph-name="kiss-beam" unicode="" horiz-adv-x="496"
|
||||
d="M168 296c23.7998 0 52.7002 -29.2998 55.7998 -71.4004c0.299805 -3.7998 -2 -7.19922 -5.59961 -8.2998c-3.10059 -1 -7.2002 0 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996c-12.3008 0 -23.8008 -7.89941 -31.5 -21.5996l-9.5 -17
|
||||
c-1.80078 -3.2002 -5.80078 -4.7002 -9.30078 -3.7002c-3.59961 1.10059 -5.89941 4.60059 -5.59961 8.2998c3.2998 42.1006 32.2002 71.4004 56 71.4004zM248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8
|
||||
c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM304 140c0 -13 -13.4004 -27.2998 -35.0996 -36.4004c21.7998 -8.69922 35.1992 -23 35.1992 -36c0 -19.1992 -28.6992 -41.5 -71.5 -44h-0.5
|
||||
c-3.69922 0 -7 2.60059 -7.7998 6.2002c-0.899414 3.7998 1.10059 7.7002 4.7002 9.2002l17 7.2002c12.9004 5.5 20.7002 13.5 20.7002 21.5s-7.7998 16 -20.7998 21.5l-16.9004 7.2002c-6 2.59961 -5.7002 12.3994 0 14.7998l17 7.2002
|
||||
c12.9004 5.5 20.7002 13.5 20.7002 21.5s-7.7998 16 -20.7998 21.5l-16.9004 7.19922c-3.59961 1.5 -5.59961 5.40039 -4.7002 9.2002c0.799805 3.7998 4.40039 6.60059 8.2002 6.2002c42.7002 -2.5 71.5 -24.7998 71.5 -44zM328 296
|
||||
c23.7998 0 52.7002 -29.2998 55.7998 -71.4004c0.299805 -3.7998 -2 -7.19922 -5.59961 -8.2998c-3.10059 -1 -7.2002 0 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996c-12.3008 0 -23.8008 -7.89941 -31.5 -21.5996l-9.5 -17
|
||||
c-1.80078 -3.2002 -5.80078 -4.7002 -9.30078 -3.7002c-3.59961 1.10059 -5.89941 4.60059 -5.59961 8.2998c3.2998 42.1006 32.2002 71.4004 56 71.4004z" />
|
||||
<glyph glyph-name="kiss-wink-heart" unicode="" horiz-adv-x="504"
|
||||
d="M304 139.5c0 -13 -13.4004 -27.2998 -35.0996 -36.4004c21.7998 -8.69922 35.1992 -23 35.1992 -36c0 -19.1992 -28.6992 -41.5 -71.5 -44h-0.5c-3.69922 0 -7 2.60059 -7.7998 6.2002c-0.899414 3.7998 1.10059 7.7002 4.7002 9.2002l17 7.2002
|
||||
c12.9004 5.5 20.7002 13.5 20.7002 21.5s-7.7998 16 -20.7998 21.5l-16.9004 7.2002c-6 2.59961 -5.7002 12.3994 0 14.7998l17 7.2002c12.9004 5.5 20.7002 13.5 20.7002 21.5s-7.7998 16 -20.7998 21.5l-16.9004 7.19922c-3.59961 1.5 -5.59961 5.40039 -4.7002 9.2002
|
||||
c0.799805 3.7998 4.40039 6.60059 8.2002 6.2002c42.7002 -2.5 71.5 -24.7998 71.5 -44zM374.5 223c-14.7998 13.2002 -46.2002 13.2002 -61 0l-9.5 -8.5c-2.5 -2.2998 -7.90039 -4.7002 -13.7002 -1.59961c-4.39941 2.39941 -6.89941 7.39941 -6.09961 12.3994
|
||||
c3.89941 25.2002 34.2002 42.1006 59.7998 42.1006s55.7998 -16.9004 59.7998 -42.1006c0.799805 -5 -1.7002 -10 -6.09961 -12.3994c-4.40039 -2.40039 -9.90039 -1.7002 -13.7002 1.59961zM136 239.5c0 17.7002 14.2998 32 32 32s32 -14.2998 32 -32s-14.2998 -32 -32 -32
|
||||
s-32 14.2998 -32 32zM501.1 45.5c9.2002 -23.9004 -4.39941 -49.4004 -28.5 -55.7002l-83 -21.5c-5.39941 -1.39941 -10.8994 1.7998 -12.3994 7.10059l-22.9004 82.5996c-6.59961 24 8.7998 48.5996 34 52.5996c22 3.5 43.1006 -11.5996 49 -33l2.2998 -8.39941
|
||||
l8.40039 2.2002c21.5996 5.59961 45.0996 -5.10059 53.0996 -25.9004zM334 11.7002c17.7002 -64 10.9004 -39.5 13.4004 -46.7998c-30.5 -13.4004 -64 -20.9004 -99.4004 -20.9004c-137 0 -248 111 -248 248s111 248 248 248s248 -111 247.9 -248
|
||||
c0 -31.7998 -6.2002 -62.0996 -17.1006 -90c-6 1.5 -12.2002 2.7998 -18.5996 2.90039c-5.60059 9.69922 -13.6006 17.5 -22.6006 23.8994c6.7002 19.9004 10.4004 41.1006 10.4004 63.2002c0 110.3 -89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200
|
||||
c30.7998 0 59.9004 7.2002 86 19.7002z" />
|
||||
<glyph glyph-name="laugh" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM389.4 50.5996c37.7998 37.8008 58.5996 88 58.5996 141.4s-20.7998 103.6 -58.5996 141.4c-37.8008 37.7998 -88 58.5996 -141.4 58.5996s-103.6 -20.7998 -141.4 -58.5996
|
||||
c-37.7998 -37.8008 -58.5996 -88 -58.5996 -141.4s20.7998 -103.6 58.5996 -141.4c37.8008 -37.7998 88 -58.5996 141.4 -58.5996s103.6 20.7998 141.4 58.5996zM328 224c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM168 224
|
||||
c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM362.4 160c8.19922 0 14.5 -7 13.5 -15c-7.5 -59.2002 -58.9004 -105 -121.101 -105h-13.5996c-62.2002 0 -113.601 45.7998 -121.101 105c-1 8 5.30078 15 13.5 15h228.801z" />
|
||||
<glyph glyph-name="laugh-beam" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM389.4 50.5996c37.7998 37.8008 58.5996 88 58.5996 141.4s-20.7998 103.6 -58.5996 141.4c-37.8008 37.7998 -88 58.5996 -141.4 58.5996s-103.6 -20.7998 -141.4 -58.5996
|
||||
c-37.7998 -37.8008 -58.5996 -88 -58.5996 -141.4s20.7998 -103.6 58.5996 -141.4c37.8008 -37.7998 88 -58.5996 141.4 -58.5996s103.6 20.7998 141.4 58.5996zM328 296c23.7998 0 52.7002 -29.2998 55.7998 -71.4004c0.700195 -8.5 -10.7998 -11.8994 -14.8994 -4.5
|
||||
l-9.5 17c-7.7002 13.7002 -19.2002 21.6006 -31.5 21.6006c-12.3008 0 -23.8008 -7.90039 -31.5 -21.6006l-9.5 -17c-4.10059 -7.39941 -15.6006 -4.09961 -14.9004 4.5c3.2998 42.1006 32.2002 71.4004 56 71.4004zM127 220.1c-4.2002 -7.39941 -15.7002 -4 -15.0996 4.5
|
||||
c3.2998 42.1006 32.1992 71.4004 56 71.4004c23.7998 0 52.6992 -29.2998 56 -71.4004c0.699219 -8.5 -10.8008 -11.8994 -14.9004 -4.5l-9.5 17c-7.7002 13.7002 -19.2002 21.6006 -31.5 21.6006s-23.7998 -7.90039 -31.5 -21.6006zM362.4 160c8.19922 0 14.5 -7 13.5 -15
|
||||
c-7.5 -59.2002 -58.9004 -105 -121.101 -105h-13.5996c-62.2002 0 -113.601 45.7998 -121.101 105c-1 8 5.30078 15 13.5 15h228.801z" />
|
||||
<glyph glyph-name="laugh-squint" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM389.4 50.5996c37.7998 37.8008 58.5996 88 58.5996 141.4s-20.7998 103.6 -58.5996 141.4c-37.8008 37.7998 -88 58.5996 -141.4 58.5996s-103.6 -20.7998 -141.4 -58.5996
|
||||
c-37.7998 -37.8008 -58.5996 -88 -58.5996 -141.4s20.7998 -103.6 58.5996 -141.4c37.8008 -37.7998 88 -58.5996 141.4 -58.5996s103.6 20.7998 141.4 58.5996zM343.6 252l33.6006 -40.2998c8.59961 -10.4004 -3.90039 -24.7998 -15.4004 -18l-80 48
|
||||
c-7.7998 4.7002 -7.7998 15.8994 0 20.5996l80 48c11.6006 6.7998 24 -7.7002 15.4004 -18zM134.2 193.7c-11.6006 -6.7998 -24.1006 7.59961 -15.4004 18l33.6006 40.2998l-33.6006 40.2998c-8.59961 10.2998 3.7998 24.9004 15.4004 18l80 -48
|
||||
c7.7998 -4.7002 7.7998 -15.8994 0 -20.5996zM362.4 160c8.19922 0 14.5 -7 13.5 -15c-7.5 -59.2002 -58.9004 -105 -121.101 -105h-13.5996c-62.2002 0 -113.601 45.7998 -121.101 105c-1 8 5.30078 15 13.5 15h228.801z" />
|
||||
<glyph glyph-name="laugh-wink" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM389.4 50.5996c37.7998 37.8008 58.5996 88 58.5996 141.4s-20.7998 103.6 -58.5996 141.4c-37.8008 37.7998 -88 58.5996 -141.4 58.5996s-103.6 -20.7998 -141.4 -58.5996
|
||||
c-37.7998 -37.8008 -58.5996 -88 -58.5996 -141.4s20.7998 -103.6 58.5996 -141.4c37.8008 -37.7998 88 -58.5996 141.4 -58.5996s103.6 20.7998 141.4 58.5996zM328 284c25.7002 0 55.9004 -16.9004 59.7002 -42.0996c1.7998 -11.1006 -11.2998 -18.2002 -19.7998 -10.8008
|
||||
l-9.5 8.5c-14.8008 13.2002 -46.2002 13.2002 -61 0l-9.5 -8.5c-8.30078 -7.39941 -21.5 -0.399414 -19.8008 10.8008c4 25.1992 34.2002 42.0996 59.9004 42.0996zM168 224c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32s32 -14.2998 32 -32s-14.2998 -32 -32 -32z
|
||||
M362.4 160c8.19922 0 14.5 -7 13.5 -15c-7.5 -59.2002 -58.9004 -105 -121.101 -105h-13.5996c-62.2002 0 -113.601 45.7998 -121.101 105c-1 8 5.30078 15 13.5 15h228.801z" />
|
||||
<glyph glyph-name="meh-blank" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM168 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32
|
||||
s-32 14.2998 -32 32s14.2998 32 32 32zM328 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32z" />
|
||||
<glyph glyph-name="meh-rolling-eyes" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM336 296c39.7998 0 72 -32.2002 72 -72s-32.2002 -72 -72 -72
|
||||
s-72 32.2002 -72 72s32.2002 72 72 72zM336 184c22.0996 0 40 17.9004 40 40c0 13.5996 -7.2998 25.0996 -17.7002 32.2998c1 -2.59961 1.7002 -5.39941 1.7002 -8.2998c0 -13.2998 -10.7002 -24 -24 -24s-24 10.7002 -24 24c0 3 0.700195 5.7002 1.7002 8.2998
|
||||
c-10.4004 -7.2002 -17.7002 -18.7002 -17.7002 -32.2998c0 -22.0996 17.9004 -40 40 -40zM232 224c0 -39.7998 -32.2002 -72 -72 -72s-72 32.2002 -72 72s32.2002 72 72 72s72 -32.2002 72 -72zM120 224c0 -22.0996 17.9004 -40 40 -40s40 17.9004 40 40
|
||||
c0 13.5996 -7.2998 25.0996 -17.7002 32.2998c1 -2.59961 1.7002 -5.39941 1.7002 -8.2998c0 -13.2998 -10.7002 -24 -24 -24s-24 10.7002 -24 24c0 3 0.700195 5.7002 1.7002 8.2998c-10.4004 -7.2002 -17.7002 -18.7002 -17.7002 -32.2998zM312 96
|
||||
c13.2002 0 24 -10.7998 24 -24s-10.7998 -24 -24 -24h-128c-13.2002 0 -24 10.7998 -24 24s10.7998 24 24 24h128z" />
|
||||
<glyph glyph-name="sad-cry" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM392 53.5996c34.5996 35.9004 56 84.7002 56 138.4c0 110.3 -89.7002 200 -200 200s-200 -89.7002 -200 -200c0 -53.7002 21.4004 -102.4 56 -138.4v114.4
|
||||
c0 13.2002 10.7998 24 24 24s24 -10.7998 24 -24v-151.4c28.5 -15.5996 61.2002 -24.5996 96 -24.5996s67.5 9 96 24.5996v151.4c0 13.2002 10.7998 24 24 24s24 -10.7998 24 -24v-114.4zM205.8 213.5c-5.7998 -3.2002 -11.2002 -0.700195 -13.7002 1.59961l-9.5 8.5
|
||||
c-14.7998 13.2002 -46.1992 13.2002 -61 0l-9.5 -8.5c-3.7998 -3.39941 -9.2998 -4 -13.6992 -1.59961c-4.40039 2.40039 -6.90039 7.40039 -6.10059 12.4004c3.90039 25.1992 34.2002 42.0996 59.7998 42.0996c25.6006 0 55.8008 -16.9004 59.8008 -42.0996
|
||||
c0.799805 -5 -1.7002 -10 -6.10059 -12.4004zM344 268c25.7002 0 55.9004 -16.9004 59.7998 -42.0996c0.799805 -5 -1.7002 -10 -6.09961 -12.4004c-5.7002 -3.09961 -11.2002 -0.599609 -13.7002 1.59961l-9.5 8.5c-14.7998 13.2002 -46.2002 13.2002 -61 0l-9.5 -8.5
|
||||
c-3.7998 -3.39941 -9.2002 -4 -13.7002 -1.59961c-4.39941 2.40039 -6.89941 7.40039 -6.09961 12.4004c3.89941 25.1992 34.0996 42.0996 59.7998 42.0996zM248 176c30.9004 0 56 -28.7002 56 -64s-25.0996 -64 -56 -64s-56 28.7002 -56 64s25.0996 64 56 64z" />
|
||||
<glyph glyph-name="sad-tear" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM256 144c38.0996 0 74 -16.7998 98.5 -46.0996
|
||||
c8.5 -10.2002 7.09961 -25.3008 -3.09961 -33.8008c-10.6006 -8.7998 -25.7002 -6.69922 -33.8008 3.10059c-15.2998 18.2998 -37.7998 28.7998 -61.5996 28.7998c-13.2002 0 -24 10.7998 -24 24s10.7998 24 24 24zM168 208c-17.7002 0 -32 14.2998 -32 32s14.2998 32 32 32
|
||||
s32 -14.2998 32 -32s-14.2998 -32 -32 -32zM328 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32zM162.4 173.2c2.7998 3.7002 8.39941 3.7002 11.1992 0c11.4004 -15.2998 36.4004 -50.6006 36.4004 -68.1006
|
||||
c0 -22.6992 -18.7998 -41.0996 -42 -41.0996s-42 18.4004 -42 41.0996c0 17.5 25 52.8008 36.4004 68.1006z" />
|
||||
<glyph glyph-name="smile-beam" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM332 135.4c8.5 10.1992 23.5996 11.5 33.7998 3.09961
|
||||
c10.2002 -8.5 11.6006 -23.5996 3.10059 -33.7998c-30 -36 -74.1006 -56.6006 -120.9 -56.6006s-90.9004 20.6006 -120.9 56.6006c-8.39941 10.2002 -7.09961 25.2998 3.10059 33.7998c10.2002 8.40039 25.2998 7.09961 33.7998 -3.09961
|
||||
c20.7998 -25.1006 51.5 -39.4004 84 -39.4004s63.2002 14.4004 84 39.4004zM136.5 237l-9.5 -17c-1.90039 -3.2002 -5.90039 -4.7998 -9.2998 -3.7002c-3.60059 1.10059 -6 4.60059 -5.7002 8.2998c3.2998 42.1006 32.2002 71.4004 56 71.4004s52.7002 -29.2998 56 -71.4004
|
||||
c0.299805 -3.7998 -2.09961 -7.19922 -5.7002 -8.2998c-3.09961 -1 -7.2002 0 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996s-23.7998 -7.89941 -31.5 -21.5996zM328 296c23.7998 0 52.7002 -29.2998 56 -71.4004
|
||||
c0.299805 -3.7998 -2.09961 -7.19922 -5.7002 -8.2998c-3.09961 -1 -7.2002 0 -9.2998 3.7002l-9.5 17c-7.7002 13.7002 -19.2002 21.5996 -31.5 21.5996s-23.7998 -7.89941 -31.5 -21.5996l-9.5 -17c-1.90039 -3.2002 -5.7998 -4.7998 -9.2998 -3.7002
|
||||
c-3.60059 1.10059 -6 4.60059 -5.7002 8.2998c3.2998 42.1006 32.2002 71.4004 56 71.4004z" />
|
||||
<glyph glyph-name="surprise" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM248 168c35.2998 0 64 -28.7002 64 -64s-28.7002 -64 -64 -64
|
||||
s-64 28.7002 -64 64s28.7002 64 64 64zM200 240c0 -17.7002 -14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32s32 -14.2998 32 -32zM328 272c17.7002 0 32 -14.2998 32 -32s-14.2998 -32 -32 -32s-32 14.2998 -32 32s14.2998 32 32 32z" />
|
||||
<glyph glyph-name="tired" unicode="" horiz-adv-x="496"
|
||||
d="M248 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM248 -8c110.3 0 200 89.7002 200 200s-89.7002 200 -200 200s-200 -89.7002 -200 -200s89.7002 -200 200 -200zM377.1 295.8c3.80078 -4.39941 3.90039 -11 0.100586 -15.5
|
||||
l-33.6006 -40.2998l33.6006 -40.2998c3.7998 -4.5 3.7002 -11 -0.100586 -15.5c-3.5 -4.10059 -9.89941 -5.7002 -15.2998 -2.5l-80 48c-3.59961 2.2002 -5.7998 6.09961 -5.7998 10.2998s2.2002 8.09961 5.7998 10.2998l80 48c5 2.90039 11.5 1.90039 15.2998 -2.5z
|
||||
M220 240c0 -4.2002 -2.2002 -8.09961 -5.7998 -10.2998l-80 -48c-5.40039 -3.2002 -11.7998 -1.60059 -15.2998 2.5c-3.80078 4.5 -3.90039 11 -0.100586 15.5l33.6006 40.2998l-33.6006 40.2998c-3.7998 4.5 -3.7002 11 0.100586 15.5
|
||||
c3.7998 4.40039 10.2998 5.5 15.2998 2.5l80 -48c3.59961 -2.2002 5.7998 -6.09961 5.7998 -10.2998zM248 176c45.4004 0 100.9 -38.2998 107.8 -93.2998c1.5 -11.9004 -7 -21.6006 -15.5 -17.9004c-22.7002 9.7002 -56.2998 15.2002 -92.2998 15.2002
|
||||
s-69.5996 -5.5 -92.2998 -15.2002c-8.60059 -3.7002 -17 6.10059 -15.5 17.9004c6.89941 55 62.3994 93.2998 107.8 93.2998z" />
|
||||
</font>
|
||||
</defs></svg>
|
||||
|
After Width: | Height: | Size: 141 KiB |
BIN
frontend/src/assets/fonts/fa-regular-400.ttf
Normal file
BIN
frontend/src/assets/fonts/fa-regular-400.woff
Normal file
BIN
frontend/src/assets/fonts/fa-regular-400.woff2
Normal file
BIN
frontend/src/assets/fonts/fa-solid-900.eot
Normal file
5028
frontend/src/assets/fonts/fa-solid-900.svg
Normal file
|
After Width: | Height: | Size: 896 KiB |
BIN
frontend/src/assets/fonts/fa-solid-900.ttf
Normal file
BIN
frontend/src/assets/fonts/fa-solid-900.woff
Normal file
BIN
frontend/src/assets/fonts/fa-solid-900.woff2
Normal file
BIN
frontend/src/assets/fonts/label/Inter-Regular.woff2
Normal file
BIN
frontend/src/assets/fonts/pixel/Chava.ttf
Normal file
BIN
frontend/src/assets/fonts/pixel/Pixelon.ttf
Normal file
BIN
frontend/src/assets/fonts/pixel/Silkscreen.ttf
Normal file
BIN
frontend/src/assets/fonts/pixel/Terminus.ttf
Normal file
25141
frontend/src/assets/js/app.js
Normal file
220
frontend/src/cameraManager.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Shared camera stream manager for webcam access (WebcamFileSource, Scan) - centralizes device
|
||||
// enumeration, preferred-camera memory, stream reuse, and disconnect/reconnect handling.
|
||||
class CameraManager {
|
||||
constructor() {
|
||||
this.availableCameras = [];
|
||||
this.activeStream = null;
|
||||
this.selectedCameraId = null;
|
||||
this.isInitialized = false;
|
||||
this.streamUsers = 0;
|
||||
}
|
||||
|
||||
async enumerateDevices() {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const videoDevices = devices.filter((d) => d.kind === 'videoinput' && d.deviceId);
|
||||
|
||||
if (videoDevices.length === 0) return this.availableCameras;
|
||||
|
||||
const uniqueMap = new Map();
|
||||
videoDevices.forEach((device) => uniqueMap.set(device.deviceId, device));
|
||||
|
||||
this.availableCameras = Array.from(uniqueMap.values());
|
||||
return this.availableCameras;
|
||||
} catch (err) {
|
||||
console.error('Error enumerating devices:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
loadPreferredCamera() {
|
||||
const savedCameras = this.getRecentCameras();
|
||||
if (savedCameras.length === 0) return null;
|
||||
|
||||
if (this.availableCameras.length === 0) {
|
||||
this.selectedCameraId = savedCameras[0];
|
||||
return savedCameras[0];
|
||||
}
|
||||
|
||||
for (const cameraId of savedCameras) {
|
||||
if (this.availableCameras.some((cam) => cam.deviceId === cameraId)) {
|
||||
this.selectedCameraId = cameraId;
|
||||
return cameraId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getRecentCameras() {
|
||||
try {
|
||||
const saved = localStorage.getItem('recentCameraIds');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch (err) {
|
||||
console.error('Error loading recent cameras:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
savePreferredCamera(cameraId) {
|
||||
if (!cameraId) return;
|
||||
|
||||
const recentCameras = this.getRecentCameras();
|
||||
const updated = [cameraId, ...recentCameras.filter((id) => id !== cameraId)];
|
||||
|
||||
try {
|
||||
localStorage.setItem('recentCameraIds', JSON.stringify(updated));
|
||||
this.selectedCameraId = cameraId;
|
||||
} catch (err) {
|
||||
console.error('Error saving recent cameras:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async openStream(cameraId = null) {
|
||||
const targetCameraId = cameraId || this.selectedCameraId;
|
||||
|
||||
if (this.activeStream) {
|
||||
const currentSettings = this.activeStream.getVideoTracks()[0].getSettings();
|
||||
if (currentSettings.deviceId === targetCameraId) {
|
||||
this.streamUsers++;
|
||||
return this.activeStream;
|
||||
}
|
||||
this.closeStream(true);
|
||||
}
|
||||
|
||||
if (!this.isInitialized) {
|
||||
await this.enumerateDevices();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
|
||||
this.selectedCameraId = this._selectCamera(targetCameraId);
|
||||
|
||||
const constraints = {
|
||||
video: this.selectedCameraId
|
||||
? { deviceId: { ideal: this.selectedCameraId } }
|
||||
: { facingMode: 'environment' },
|
||||
audio: false,
|
||||
};
|
||||
|
||||
try {
|
||||
this.activeStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
this.streamUsers = 1;
|
||||
await this.enumerateDevices();
|
||||
|
||||
const actualDeviceId = this.activeStream.getVideoTracks()[0]?.getSettings().deviceId;
|
||||
if (actualDeviceId) this.selectedCameraId = actualDeviceId;
|
||||
|
||||
this.activeStream.getVideoTracks().forEach((track) => {
|
||||
track.onended = () => this.handleTrackEnded(track);
|
||||
});
|
||||
|
||||
return this.activeStream;
|
||||
} catch (err) {
|
||||
console.error('Error opening camera stream:', err);
|
||||
if (err.name === 'OverconstrainedError' && this.selectedCameraId) {
|
||||
this.selectedCameraId = null;
|
||||
localStorage.removeItem('recentCameraIds');
|
||||
return await this.openStream();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
_selectCamera(targetCameraId) {
|
||||
if (targetCameraId) return targetCameraId;
|
||||
|
||||
const preferredCamera = this.loadPreferredCamera();
|
||||
if (preferredCamera) return preferredCamera;
|
||||
|
||||
return this.availableCameras[0]?.deviceId || null;
|
||||
}
|
||||
|
||||
async switchCamera(cameraId) {
|
||||
this.savePreferredCamera(cameraId);
|
||||
const wasActive = this.streamUsers > 0;
|
||||
this.closeStream(true);
|
||||
return wasActive ? await this.openStream(cameraId) : null;
|
||||
}
|
||||
|
||||
async handleTrackEnded(track) {
|
||||
console.warn('Camera track ended (may have been unplugged)');
|
||||
const disconnectedDeviceId = track.getSettings().deviceId;
|
||||
|
||||
this.activeStream = null;
|
||||
this.streamUsers = 0;
|
||||
await this.enumerateDevices();
|
||||
|
||||
if (this.availableCameras.some((cam) => cam.deviceId === disconnectedDeviceId)) return;
|
||||
|
||||
if (this.availableCameras.length === 0) {
|
||||
console.error('No cameras available after disconnection');
|
||||
this._dispatchCameraEvent('camera-disconnected', { error: 'No cameras available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackCameraId = this._selectFallbackCamera();
|
||||
const fallbackCamera = this.availableCameras.find((cam) => cam.deviceId === fallbackCameraId);
|
||||
|
||||
try {
|
||||
await this.openStream(fallbackCameraId);
|
||||
this._dispatchCameraEvent('camera-reconnected', {
|
||||
deviceId: fallbackCameraId,
|
||||
label: fallbackCamera?.label || 'Unknown',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to open fallback camera:', err);
|
||||
this._dispatchCameraEvent('camera-disconnected', { error: 'No cameras available' });
|
||||
}
|
||||
}
|
||||
|
||||
_selectFallbackCamera() {
|
||||
const recentCameras = this.getRecentCameras();
|
||||
for (const cameraId of recentCameras) {
|
||||
if (this.availableCameras.some((cam) => cam.deviceId === cameraId)) {
|
||||
return cameraId;
|
||||
}
|
||||
}
|
||||
|
||||
return this.availableCameras[0]?.deviceId;
|
||||
}
|
||||
|
||||
_cleanCameraLabel(label) {
|
||||
if (!label) return 'Unknown';
|
||||
const parts = label.split(':').map((p) => p.trim());
|
||||
if (parts.length === 2 && parts[0] === parts[1]) {
|
||||
return parts[0];
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
_dispatchCameraEvent(eventName, detail) {
|
||||
if (detail.label) {
|
||||
detail.label = this._cleanCameraLabel(detail.label);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
}
|
||||
|
||||
closeStream(force = false) {
|
||||
if (!this.activeStream) return;
|
||||
|
||||
this.streamUsers = force ? 0 : Math.max(0, this.streamUsers - 1);
|
||||
|
||||
if (this.streamUsers === 0) {
|
||||
this.activeStream.getTracks().forEach((track) => track.stop());
|
||||
this.activeStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
getAvailableCameras() {
|
||||
return this.availableCameras;
|
||||
}
|
||||
|
||||
getActiveStream() {
|
||||
return this.activeStream;
|
||||
}
|
||||
|
||||
getSelectedCameraId() {
|
||||
return this.selectedCameraId;
|
||||
}
|
||||
}
|
||||
|
||||
export default new CameraManager();
|
||||
0
frontend/src/components/AuthenticatedAvatar.vue
Normal file
111
frontend/src/components/AuthenticatedImage.vue
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<img :src="image_data" :title="title || (owner + ':' + src)" :alt="alt" :class="imgClass"
|
||||
:width="width" :height="height" :style="{objectFit: fit}"/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters} from "vuex";
|
||||
import fileCache from "../fileCache";
|
||||
|
||||
export default {
|
||||
name: "AuthenticatedImage",
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
owner: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: 'image'
|
||||
},
|
||||
imgClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fallbackSrc: {
|
||||
type: String,
|
||||
default: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+CjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEyIiBmaWxsPSIjY2VkNGRhIi8+CjxjaXJjbGUgY3g9IjEyIiBjeT0iOS41IiByPSI0IiBmaWxsPSIjZmZmZmZmIi8+CjxwYXRoIGQ9Ik00IDIwLjJDNCAxNi4yIDcuNiAxMy41IDEyIDEzLjVzOCAyLjcgOCA2Ljd2MC4zSDR2LTAuM3oiIGZpbGw9IiNmZmZmZmYiLz4KPC9zdmc+Cg=='
|
||||
},
|
||||
width: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
height: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
fit: {
|
||||
type: String,
|
||||
default: 'contain'
|
||||
},
|
||||
refreshKey: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
image_data: "",
|
||||
servers: [],
|
||||
lastRequestId: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["signAuth"])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["getFriendServers"]),
|
||||
async loadImage() {
|
||||
const requestId = ++this.lastRequestId;
|
||||
if (!this.src) {
|
||||
if (requestId === this.lastRequestId) {
|
||||
this.image_data = this.fallbackSrc;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Cached by src alone: content is hash-addressed and immutable (see fileCache.js),
|
||||
// so every instance showing the same file shares one fetch and blob.
|
||||
const url = await fileCache.get(this.src, async () => {
|
||||
this.servers = await this.getFriendServers({username: this.owner});
|
||||
const response = await this.servers.getRaw(this.signAuth, this.src);
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('failed to fetch ' + this.src);
|
||||
}
|
||||
return await response.blob();
|
||||
});
|
||||
if (requestId === this.lastRequestId) {
|
||||
this.image_data = url;
|
||||
}
|
||||
} catch (_e) {
|
||||
if (requestId === this.lastRequestId) {
|
||||
this.image_data = this.fallbackSrc;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src() {
|
||||
this.loadImage();
|
||||
},
|
||||
owner() {
|
||||
this.loadImage();
|
||||
},
|
||||
refreshKey() {
|
||||
this.loadImage();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadImage();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,11 +1,22 @@
|
|||
<template>
|
||||
<div class="wrapper">
|
||||
<Sidebar/>
|
||||
<div class="main">
|
||||
<div class="main" id="page">
|
||||
<nav class="navbar navbar-expand navbar-light navbar-bg">
|
||||
<a class="sidebar-toggle d-flex" @click="toggleSidebar">
|
||||
<i class="hamburger align-self-center"></i>
|
||||
</a>
|
||||
<SearchBox/>
|
||||
<div class="navbar-collapse collapse">
|
||||
<ul class="navbar-nav navbar-align">
|
||||
<router-link to="/scan?navigate=immediate" class="nav-icon">
|
||||
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
|
||||
</router-link>
|
||||
<!--Notifications :notifications="notifications"/-->
|
||||
<!--Messages :messages="messages"/-->
|
||||
<UserDropdown/>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<slot></slot>
|
||||
<Footer/>
|
||||
|
|
@ -14,14 +25,27 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapGetters, mapMutations, mapState} from 'vuex';
|
||||
import Notifications from "@/components/Notifications.vue";
|
||||
import Messages from "@/components/Messages.vue";
|
||||
import Footer from "@/components/Footer.vue";
|
||||
import Sidebar from "@/components/Sidebar.vue";
|
||||
import UserDropdown from "@/components/UserDropdown.vue";
|
||||
import SearchBox from "@/components/SearchBox.vue";
|
||||
|
||||
export default {
|
||||
name: 'BaseLayout',
|
||||
components: {
|
||||
SearchBox,
|
||||
UserDropdown,
|
||||
Sidebar,
|
||||
Footer,
|
||||
Sidebar
|
||||
Messages,
|
||||
Notifications
|
||||
},
|
||||
computed: {
|
||||
...mapState(['messages']),
|
||||
...mapGetters(['notifications']),
|
||||
},
|
||||
props: {
|
||||
hideSearch: {
|
||||
|
|
@ -34,6 +58,7 @@ export default {
|
|||
toggleSidebar() {
|
||||
closeAllDropdowns();
|
||||
document.getElementById("sidebar").classList.toggle("collapsed");
|
||||
document.getElementById("page").classList.toggle("expanded");
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -52,11 +77,22 @@ export default {
|
|||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 100vh;
|
||||
margin-left: 260px;
|
||||
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main.expanded {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1px) and (max-width: 991.98px) {
|
||||
.main, .main.expanded {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-expand {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
|
|
@ -68,6 +104,10 @@ export default {
|
|||
box-shadow: 0 0 2rem var(--bs-shadow)
|
||||
}
|
||||
|
||||
.navbar-align {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.navbar-bg {
|
||||
background: var(--bs-white);
|
||||
}
|
||||
|
|
@ -112,4 +152,43 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.nav-flag::after, .nav-icon::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-flag .feather, .nav-flag svg, .nav-icon .feather, .nav-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.nav-flag, .nav-icon {
|
||||
padding: .1rem .8rem;
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
color: #6c757d;
|
||||
transition: background .1s ease-in-out, color .1s ease-in-out;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.nav-item .indicator {
|
||||
background: #3b7ddd;
|
||||
box-shadow: 0 .1rem .2rem #0000000d;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
padding: 1px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -8px;
|
||||
text-align: center;
|
||||
transition: top .1s ease-out;
|
||||
font-size: .675rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
</style>
|
||||
618
frontend/src/components/CameraScanner.vue
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
<template>
|
||||
<div class="camera-scanner">
|
||||
<p v-if="!cameraSupported" class="text-muted">
|
||||
This browser has no camera API available.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p v-if="cameraRes" class="text-muted small">{{ cameraRes }}</p>
|
||||
<div class="video-wrap">
|
||||
<video ref="video" autoplay muted playsinline></video>
|
||||
<canvas ref="overlay"></canvas>
|
||||
</div>
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-auto flex-grow-1">
|
||||
<select class="form-control" v-model="selectedCameraId">
|
||||
<option value="">Auto (prefer recent, then rear camera)</option>
|
||||
<option v-for="(c, i) in cameras" :key="c.deviceId"
|
||||
:value="c.deviceId">
|
||||
{{ cleanCameraLabel(c.label) || ('Camera ' + (i + 1)) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="cameraRunning" class="col-auto">
|
||||
<button class="btn btn-outline-secondary" @click="stopCamera">
|
||||
<b-icon-stop-fill class="me-1"></b-icon-stop-fill>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {loadAnyDCode, toGrayFrame} from "../../vendor/anyd-qr.js";
|
||||
// Same file as above, but as a plain URL (Vite's `?url` suffix skips bundling it into this
|
||||
// module) - the decode worker built in createDecodeWorker() below needs a fetchable/importable
|
||||
// URL for its own, independent `import()`, since a worker has no access to this module's own
|
||||
// import bindings.
|
||||
import anydUrl from "../../vendor/anyd-qr.js?url";
|
||||
import cameraManager from "@/cameraManager.js";
|
||||
|
||||
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
|
||||
// the code has moved out of frame.
|
||||
const OVERLAY_CLEAR_MS = 2000;
|
||||
|
||||
// Capture-loop tuning, matching prototypes/anydcode-wasm's advanced camera demo's own
|
||||
// defaults/findings (see its parameter-sweep-findings.md) but at a rate suited to a
|
||||
// battery-conscious inventory-scanning UI rather than that demo's max-responsiveness dial.
|
||||
const CAPTURE_FPS = 4;
|
||||
const CAPTURE_DOWNSCALE = 2;
|
||||
|
||||
// Milliseconds to suppress re-emitting a symbology+text pair already reported - independent of
|
||||
// (and shorter than) COOLDOWN_MS below, since it throttles noisy duplicate "scan" events even
|
||||
// on ticks where a candidate did get decoded (e.g. because the worker was otherwise idle).
|
||||
const EMIT_DEDUPE_MS = 800;
|
||||
|
||||
// Location-based cooldown: skip re-decoding a candidate whose box overlaps a recent successful
|
||||
// decode while the worker has other work pending, so a code sitting still in frame doesn't keep
|
||||
// re-paying decode2d's cost every tick. Ported the IoU-overlap idea (not the code) from
|
||||
// prototypes/anydcode-wasm/packages/qr-wasm/advanced_demo/pipeline.js's createCooldownTracker.
|
||||
const COOLDOWN_MS = 1500;
|
||||
const COOLDOWN_IOU_THRESHOLD = 0.3;
|
||||
|
||||
// A crop bigger than this (px, longer side) is almost always oversampled for its module count,
|
||||
// so shrink it before decode2d - cheaper, and no less reliable for a close-up code - capped at
|
||||
// MAX_DECODE_DOWNSCALE so a genuinely complex/high-version code that happens to fill the frame
|
||||
// isn't pushed below a safe pixels-per-module floor. Values as validated by the advanced demo's
|
||||
// parameter sweep (see its parameter-sweep-findings.md).
|
||||
const DECODE_TARGET_CROP_SIZE = 400;
|
||||
const MAX_DECODE_DOWNSCALE = 3;
|
||||
|
||||
// A queued decode job older than this is dropped unsent rather than dispatched - by the time the
|
||||
// (single) worker would reach it, the frame it came from is stale enough that decoding it is
|
||||
// wasted work. MAX_DECODE_QUEUE bounds how many candidates can be waiting at once; there's only
|
||||
// one worker, so this is a queue depth, not a pool size.
|
||||
const MAX_QUEUE_AGE_MS = 1000;
|
||||
const MAX_DECODE_QUEUE = 4;
|
||||
|
||||
function boundingBox(corners) {
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const [x, y] of corners) {
|
||||
if (x < x0) x0 = x;
|
||||
if (y < y0) y0 = y;
|
||||
if (x > x1) x1 = x;
|
||||
if (y > y1) y1 = y;
|
||||
}
|
||||
return {x0, y0, x1, y1};
|
||||
}
|
||||
|
||||
// Intersection-over-union of two `{x0,y0,x1,y1}` boxes, 0 (no overlap) to 1 (identical).
|
||||
function iou(a, b) {
|
||||
const x0 = Math.max(a.x0, b.x0);
|
||||
const y0 = Math.max(a.y0, b.y0);
|
||||
const x1 = Math.min(a.x1, b.x1);
|
||||
const y1 = Math.min(a.y1, b.y1);
|
||||
const interArea = Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
|
||||
if (interArea === 0) return 0;
|
||||
const areaA = (a.x1 - a.x0) * (a.y1 - a.y0);
|
||||
const areaB = (b.x1 - b.x0) * (b.y1 - b.y0);
|
||||
return interArea / (areaA + areaB - interArea);
|
||||
}
|
||||
|
||||
// Crop a sub-frame out of `luma` (row-major, `width`x`height`), expanding by `pad` pixels and
|
||||
// clamping to the source bounds, so a tight `locate()` box still leaves the decoder its quiet
|
||||
// zone.
|
||||
function cropGray(width, height, luma, box, pad) {
|
||||
const x0 = Math.max(0, Math.floor(box.x0 - pad));
|
||||
const y0 = Math.max(0, Math.floor(box.y0 - pad));
|
||||
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
|
||||
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
|
||||
const w = Math.max(1, x1 - x0);
|
||||
const h = Math.max(1, y1 - y0);
|
||||
const out = new Uint8Array(w * h);
|
||||
for (let row = 0; row < h; row++) {
|
||||
const srcStart = (y0 + row) * width + x0;
|
||||
out.set(luma.subarray(srcStart, srcStart + w), row * w);
|
||||
}
|
||||
return {width: w, height: h, luma: out, offsetX: x0, offsetY: y0};
|
||||
}
|
||||
|
||||
// Pick how much to shrink this candidate's crop before decode2d(), from its own detected size -
|
||||
// `1` (no change) for the common small/typical case; only a candidate detected as unusually
|
||||
// large gets downscaled, and only by as much as its size actually calls for.
|
||||
function chooseDecodeDownscale(box, targetSize, maxDownscale) {
|
||||
const size = Math.max(box.x1 - box.x0, box.y1 - box.y0);
|
||||
return Math.min(maxDownscale, Math.max(1, Math.floor(size / targetSize)));
|
||||
}
|
||||
|
||||
// Crop `box` (expanded by `pad`, clamped to the frame) for decoding, downscaling by
|
||||
// `decodeDownscale` in the same step when that's `> 1`. At `1` this is exactly `cropGray`; a
|
||||
// real downscale instead re-samples from `sourceCanvas` (the RGBA source) so the browser's own
|
||||
// image scaling does the antialiased averaging a manual subsample of an already-grayscale array
|
||||
// would not.
|
||||
function cropForDecode(width, height, luma, box, pad, decodeDownscale, sourceCanvas, scratchCanvas, scratchCtx) {
|
||||
if (decodeDownscale === 1) {
|
||||
return {...cropGray(width, height, luma, box, pad), decodeDownscale};
|
||||
}
|
||||
const x0 = Math.max(0, Math.floor(box.x0 - pad));
|
||||
const y0 = Math.max(0, Math.floor(box.y0 - pad));
|
||||
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
|
||||
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
|
||||
const srcW = Math.max(1, x1 - x0);
|
||||
const srcH = Math.max(1, y1 - y0);
|
||||
const dstW = Math.max(1, Math.round(srcW / decodeDownscale));
|
||||
const dstH = Math.max(1, Math.round(srcH / decodeDownscale));
|
||||
if (scratchCanvas.width !== dstW || scratchCanvas.height !== dstH) {
|
||||
scratchCanvas.width = dstW;
|
||||
scratchCanvas.height = dstH;
|
||||
}
|
||||
scratchCtx.drawImage(sourceCanvas, x0, y0, srcW, srcH, 0, 0, dstW, dstH);
|
||||
const cropLuma = toGrayFrame(scratchCtx.getImageData(0, 0, dstW, dstH)).luma;
|
||||
return {width: dstW, height: dstH, luma: cropLuma, offsetX: x0, offsetY: y0, decodeDownscale};
|
||||
}
|
||||
|
||||
// Builds the off-main-thread decode2d()/decode1d() half of the pipeline: a single Worker (its
|
||||
// source assembled from a template string, not a separate file, to keep this pipeline contained
|
||||
// in this component) that dynamically imports the same anyd-qr.js bundle already used on the
|
||||
// main thread for locate(), so a slow decode never blocks capture, detection, or this
|
||||
// component's own overlay/UI rendering. It gets its own wasm instance - a worker's wasm memory
|
||||
// isn't shared with the main thread - via that import's own `loadAnyDCode()` cache, exactly like
|
||||
// the main thread's `anydPromise` below.
|
||||
function createDecodeWorker() {
|
||||
const absoluteAnydUrl = new URL(anydUrl, window.location.href).href;
|
||||
const source = `
|
||||
import { loadAnyDCode } from ${JSON.stringify(absoluteAnydUrl)};
|
||||
const anydPromise = loadAnyDCode();
|
||||
self.onmessage = async (event) => {
|
||||
const {jobId, width, height, luma} = event.data;
|
||||
try {
|
||||
const anyd = await anydPromise;
|
||||
const codes = anyd.decodeImage({width, height, luma: new Uint8Array(luma)});
|
||||
self.postMessage({type: "result", jobId, codes});
|
||||
} catch (err) {
|
||||
self.postMessage({type: "error", jobId, message: err?.message ?? String(err)});
|
||||
}
|
||||
};
|
||||
`;
|
||||
const blobUrl = URL.createObjectURL(new Blob([source], {type: "application/javascript"}));
|
||||
const worker = new Worker(blobUrl, {type: "module"});
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return worker;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "CameraScanner",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
// Whether to open the camera as soon as it's available (still gated on browser support
|
||||
// and a secure context - the camera API is unavailable otherwise). false lets a caller
|
||||
// that wants to gate opening the camera behind its own condition (e.g. a "scan" button)
|
||||
// skip the auto-open.
|
||||
autostart: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
// Fired once per onDecode batch with anyd-qr.js's raw decoded codes (type/text/metadata/box).
|
||||
"scan",
|
||||
// Fired with a message string when a camera error occurs, or null once it clears (e.g. on
|
||||
// reconnect) - lets the caller show/clear it alongside errors from its own other sources.
|
||||
"error",
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
cameraSupported: Boolean(navigator.mediaDevices?.getUserMedia),
|
||||
cameras: [],
|
||||
localSelectedCameraId: "",
|
||||
cameraRunning: false,
|
||||
cameraRes: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// Switches camera immediately on selection change (no explicit "apply" step), matching
|
||||
// prototypes/camera-inputs/InputPhoto.vue's selectedCameraId.
|
||||
selectedCameraId: {
|
||||
get() {
|
||||
return this.localSelectedCameraId;
|
||||
},
|
||||
set(value) {
|
||||
this.localSelectedCameraId = value;
|
||||
if (this.cameraRunning) {
|
||||
this.switchCamera(value || null);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
// Device labels only populate once permission is granted, so this is called again right
|
||||
// after getUserMedia resolves in startCamera, not just once up front.
|
||||
async populateCameraList() {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||
return;
|
||||
}
|
||||
await cameraManager.enumerateDevices();
|
||||
this.cameras = cameraManager.getAvailableCameras();
|
||||
},
|
||||
|
||||
cleanCameraLabel(label) {
|
||||
if (!label) return "";
|
||||
const parts = label.split(":").map((p) => p.trim());
|
||||
if (parts.length === 2 && parts[0] === parts[1]) {
|
||||
return parts[0];
|
||||
}
|
||||
return label;
|
||||
},
|
||||
|
||||
syncOverlaySize() {
|
||||
const video = this.$refs.video;
|
||||
const overlay = this.$refs.overlay;
|
||||
if (!video || !overlay) {
|
||||
return;
|
||||
}
|
||||
overlay.width = video.clientWidth;
|
||||
overlay.height = video.clientHeight;
|
||||
},
|
||||
|
||||
// Draws each detected code's box (video's native pixel space, scaled to the overlay's
|
||||
// displayed size) and fades it out after OVERLAY_CLEAR_MS.
|
||||
drawOverlay(codes) {
|
||||
const overlay = this.$refs.overlay;
|
||||
const video = this.$refs.video;
|
||||
if (!overlay || !video || !video.videoWidth) {
|
||||
return;
|
||||
}
|
||||
const ctx = overlay.getContext("2d");
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
const scaleX = overlay.width / video.videoWidth;
|
||||
const scaleY = overlay.height / video.videoHeight;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = "#3ce77c";
|
||||
ctx.fillStyle = "#3ce77c";
|
||||
ctx.font = "14px sans-serif";
|
||||
for (const code of codes) {
|
||||
if (!code.box) {
|
||||
continue;
|
||||
}
|
||||
const {x0, y0, x1, y1} = code.box;
|
||||
ctx.strokeRect(x0 * scaleX, y0 * scaleY, (x1 - x0) * scaleX, (y1 - y0) * scaleY);
|
||||
ctx.fillText(`${code.type}: ${code.text ?? ""}`, x0 * scaleX, Math.max(14, y0 * scaleY - 4));
|
||||
}
|
||||
clearTimeout(this.clearOverlayTimer);
|
||||
this.clearOverlayTimer = setTimeout(
|
||||
() => ctx.clearRect(0, 0, overlay.width, overlay.height), OVERLAY_CLEAR_MS);
|
||||
},
|
||||
|
||||
// Attaches `stream` to the video element so the scanner never needs recreating. See
|
||||
// docs/implementation.md#video-stream-attach-and-resize-sync.
|
||||
setupVideoStream(stream) {
|
||||
const video = this.$refs.video;
|
||||
if (!video) return;
|
||||
video.srcObject = stream;
|
||||
video.play();
|
||||
},
|
||||
|
||||
// Resizes the overlay to match the video's new size once its intrinsic dimensions change.
|
||||
// See docs/implementation.md#video-stream-attach-and-resize-sync.
|
||||
onVideoResize() {
|
||||
const video = this.$refs.video;
|
||||
if (!video || !video.videoWidth) {
|
||||
return;
|
||||
}
|
||||
this.syncOverlaySize();
|
||||
this.cameraRes = `Native resolution: ${video.videoWidth} x ${video.videoHeight}`;
|
||||
},
|
||||
|
||||
async startCamera() {
|
||||
this.$emit("error", null);
|
||||
try {
|
||||
const stream = await cameraManager.openStream(this.localSelectedCameraId || null);
|
||||
this.setupVideoStream(stream);
|
||||
await this.populateCameraList();
|
||||
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
|
||||
|
||||
const anyd = await this.anydPromise;
|
||||
this.detectWasm = anyd.raw;
|
||||
this.seen = new Map();
|
||||
this.cooldownEntries = [];
|
||||
clearInterval(this.captureTimer);
|
||||
this.captureTimer = setInterval(() => this.captureTick(), 1000 / CAPTURE_FPS);
|
||||
this.cameraRunning = true;
|
||||
} catch (e) {
|
||||
this.$emit("error", `Camera error: ${e.message ?? e}`);
|
||||
}
|
||||
},
|
||||
|
||||
// The tick driving the whole pipeline: grab+downscale the current video frame, run
|
||||
// `locate()` right here on the main thread (cheap enough not to need a worker of its
|
||||
// own), then hand each candidate crop off to the decode worker rather than decoding it
|
||||
// inline - see createDecodeWorker()'s doc comment for why.
|
||||
captureTick() {
|
||||
const video = this.$refs.video;
|
||||
if (!video || !video.videoWidth || !video.videoHeight || !this.detectWasm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = Math.max(1, Math.round(video.videoWidth / CAPTURE_DOWNSCALE));
|
||||
const height = Math.max(1, Math.round(video.videoHeight / CAPTURE_DOWNSCALE));
|
||||
if (this.captureCanvas.width !== width || this.captureCanvas.height !== height) {
|
||||
this.captureCanvas.width = width;
|
||||
this.captureCanvas.height = height;
|
||||
}
|
||||
this.captureCtx.drawImage(video, 0, 0, width, height);
|
||||
const frame = toGrayFrame(this.captureCtx.getImageData(0, 0, width, height));
|
||||
|
||||
let candidates;
|
||||
try {
|
||||
candidates = this.detectWasm.locate(width, height, frame.luma);
|
||||
} catch (err) {
|
||||
this.$emit("error", `Camera error: ${err.message ?? err}`);
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(candidates)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate?.corners) continue;
|
||||
const box = boundingBox(candidate.corners);
|
||||
const nativeBox = {
|
||||
x0: box.x0 * CAPTURE_DOWNSCALE,
|
||||
y0: box.y0 * CAPTURE_DOWNSCALE,
|
||||
x1: box.x1 * CAPTURE_DOWNSCALE,
|
||||
y1: box.y1 * CAPTURE_DOWNSCALE,
|
||||
};
|
||||
|
||||
// Something decoded near here recently, and the worker has other work pending -
|
||||
// skip re-decoding this candidate rather than spend it re-reading a code that's
|
||||
// (almost certainly) still just sitting there. If the worker/queue is otherwise
|
||||
// idle, decode anyway: there's no contention to save, and it keeps this
|
||||
// location's cooldown entry from going stale between real hits.
|
||||
if ((this.decodeWorkerBusy || this.decodeQueue.length > 0) && this.findCooldownNear(nativeBox, now)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const decodeDownscale = chooseDecodeDownscale(box, DECODE_TARGET_CROP_SIZE, MAX_DECODE_DOWNSCALE);
|
||||
const crop = cropForDecode(
|
||||
width, height, frame.luma, box, 4, decodeDownscale,
|
||||
this.captureCanvas, this.decodeScratchCanvas, this.decodeScratchCtx);
|
||||
this.enqueueDecodeJob(crop, decodeDownscale, now);
|
||||
}
|
||||
},
|
||||
|
||||
enqueueDecodeJob(crop, decodeDownscale, now) {
|
||||
this.pruneStaleJobs(now);
|
||||
if (this.decodeQueue.length >= MAX_DECODE_QUEUE) {
|
||||
return;
|
||||
}
|
||||
this.decodeQueue.push({
|
||||
jobId: ++this.jobCounter,
|
||||
generation: this.captureGeneration,
|
||||
enqueuedAt: now,
|
||||
payload: {jobId: this.jobCounter, width: crop.width, height: crop.height, luma: crop.luma.buffer},
|
||||
transfer: [crop.luma.buffer],
|
||||
offsetX: crop.offsetX,
|
||||
offsetY: crop.offsetY,
|
||||
downscale: CAPTURE_DOWNSCALE,
|
||||
decodeDownscale,
|
||||
});
|
||||
this.dispatchNextJob();
|
||||
},
|
||||
|
||||
// FIFO order means the front of the queue is always the oldest, so the first
|
||||
// non-expired entry means nothing behind it can be expired either - one pass from the
|
||||
// front is sufficient.
|
||||
pruneStaleJobs(now) {
|
||||
while (this.decodeQueue.length > 0 && now - this.decodeQueue[0].enqueuedAt > MAX_QUEUE_AGE_MS) {
|
||||
this.decodeQueue.shift();
|
||||
}
|
||||
},
|
||||
|
||||
dispatchNextJob() {
|
||||
if (this.decodeWorkerBusy) {
|
||||
return;
|
||||
}
|
||||
this.pruneStaleJobs(performance.now());
|
||||
const job = this.decodeQueue.shift();
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
this.decodeWorkerBusy = true;
|
||||
this.pendingJob = job;
|
||||
this.decodeWorker.postMessage(job.payload, job.transfer);
|
||||
},
|
||||
|
||||
// The worker only ever saw this one candidate's crop, so any `box` it reports is
|
||||
// relative to that crop's own pixels - coarser than the capture frame's if this
|
||||
// candidate got its own decode-time downscale. Scale back up by that factor to land in
|
||||
// the crop's own space, then apply its offset, then the capture downscale in effect
|
||||
// when this job was dispatched, to land on native video pixels.
|
||||
handleWorkerMessage(event) {
|
||||
const msg = event.data;
|
||||
const job = this.pendingJob;
|
||||
this.decodeWorkerBusy = false;
|
||||
this.pendingJob = null;
|
||||
|
||||
if (msg.type === "error") {
|
||||
console.error("[camera scan]", msg.message);
|
||||
} else if (job && job.jobId === msg.jobId && job.generation === this.captureGeneration) {
|
||||
const now = performance.now();
|
||||
const codes = (msg.codes ?? []).map((code) => ({
|
||||
...code,
|
||||
box: code.box && {
|
||||
x0: (code.box.x0 * job.decodeDownscale + job.offsetX) * job.downscale,
|
||||
y0: (code.box.y0 * job.decodeDownscale + job.offsetY) * job.downscale,
|
||||
x1: (code.box.x1 * job.decodeDownscale + job.offsetX) * job.downscale,
|
||||
y1: (code.box.y1 * job.decodeDownscale + job.offsetY) * job.downscale,
|
||||
},
|
||||
}));
|
||||
|
||||
for (const code of codes) {
|
||||
if (code.box) this.rememberCooldown(code.box, now);
|
||||
}
|
||||
|
||||
const dedupeNow = Date.now();
|
||||
const fresh = codes.filter((code) => {
|
||||
const key = `${code.type}|${code.text ?? ""}`;
|
||||
const lastSeen = this.seen.get(key);
|
||||
if (lastSeen !== undefined && dedupeNow - lastSeen < EMIT_DEDUPE_MS) {
|
||||
return false;
|
||||
}
|
||||
this.seen.set(key, dedupeNow);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length > 0) {
|
||||
this.$emit("scan", fresh);
|
||||
this.drawOverlay(fresh);
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatchNextJob();
|
||||
},
|
||||
|
||||
rememberCooldown(box, now) {
|
||||
const existing = this.cooldownEntries.find((e) => iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
|
||||
if (existing) {
|
||||
existing.box = box;
|
||||
existing.decodedAt = now;
|
||||
} else {
|
||||
this.cooldownEntries.push({box, decodedAt: now});
|
||||
}
|
||||
// Prune well past the cooldown window so a long session doesn't accumulate forever.
|
||||
this.cooldownEntries = this.cooldownEntries.filter((e) => now - e.decodedAt < COOLDOWN_MS * 4);
|
||||
},
|
||||
|
||||
findCooldownNear(box, now) {
|
||||
return this.cooldownEntries.some(
|
||||
(e) => now - e.decodedAt < COOLDOWN_MS && iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
|
||||
},
|
||||
|
||||
async switchCamera(cameraId) {
|
||||
if (!this.cameraRunning) return;
|
||||
try {
|
||||
const stream = await cameraManager.switchCamera(cameraId);
|
||||
if (stream) this.setupVideoStream(stream);
|
||||
} catch (e) {
|
||||
this.$emit("error", `Camera error: ${e.message ?? e}`);
|
||||
}
|
||||
},
|
||||
|
||||
stopCamera() {
|
||||
clearInterval(this.captureTimer);
|
||||
this.captureTimer = null;
|
||||
this.decodeQueue = [];
|
||||
// Bumped so a decode job dispatched before this stop - still in flight in the
|
||||
// worker - is recognized as stale and discarded once its result arrives (see
|
||||
// handleWorkerMessage), rather than applied against whatever's running by then.
|
||||
// decodeWorkerBusy/pendingJob are deliberately left alone: they resolve themselves
|
||||
// when that in-flight result lands.
|
||||
this.captureGeneration++;
|
||||
cameraManager.closeStream();
|
||||
if (this.$refs.video) {
|
||||
this.$refs.video.srcObject = null;
|
||||
}
|
||||
this.cameraRunning = false;
|
||||
const overlay = this.$refs.overlay;
|
||||
if (overlay) {
|
||||
overlay.getContext("2d").clearRect(0, 0, overlay.width, overlay.height);
|
||||
}
|
||||
},
|
||||
|
||||
// cameraManager already tries a fallback device and only fires this event once none
|
||||
// remain, so the running scan session has to stop here.
|
||||
handleCameraDisconnected(event) {
|
||||
if (!this.cameraRunning) return;
|
||||
this.$emit("error", `Camera error: ${event.detail?.error || "Camera disconnected"}`);
|
||||
this.stopCamera();
|
||||
},
|
||||
|
||||
handleCameraReconnected() {
|
||||
if (!this.cameraRunning) return;
|
||||
const stream = cameraManager.getActiveStream();
|
||||
if (!stream) return;
|
||||
this.$emit("error", null);
|
||||
this.setupVideoStream(stream);
|
||||
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
|
||||
},
|
||||
},
|
||||
created() {
|
||||
// Kept as a promise (not awaited) so this and Scan.vue's own file-decode path share one
|
||||
// in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
|
||||
// free too.
|
||||
this.anydPromise = loadAnyDCode();
|
||||
this.detectWasm = null;
|
||||
this.clearOverlayTimer = null;
|
||||
this.captureTimer = null;
|
||||
this.captureGeneration = 0;
|
||||
this.seen = new Map();
|
||||
this.cooldownEntries = [];
|
||||
|
||||
// decode2d()/decode1d() run in this worker, off the main thread, so a slow decode never
|
||||
// stalls locate()/capture or this component's own rendering - see createDecodeWorker().
|
||||
// Long-lived for the component's lifetime (not recreated per start/stop) so switching
|
||||
// cameras or restarting the scan doesn't pay wasm-instantiation cost again.
|
||||
this.decodeWorker = createDecodeWorker();
|
||||
this.decodeWorker.onmessage = (event) => this.handleWorkerMessage(event);
|
||||
this.decodeWorkerBusy = false;
|
||||
this.pendingJob = null;
|
||||
this.jobCounter = 0;
|
||||
this.decodeQueue = [];
|
||||
|
||||
this.captureCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
|
||||
this.captureCtx = this.captureCanvas.getContext("2d");
|
||||
this.decodeScratchCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
|
||||
this.decodeScratchCtx = this.decodeScratchCanvas.getContext("2d");
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener("resize", this.syncOverlaySize);
|
||||
window.addEventListener("camera-disconnected", this.handleCameraDisconnected);
|
||||
window.addEventListener("camera-reconnected", this.handleCameraReconnected);
|
||||
navigator.mediaDevices?.addEventListener("devicechange", this.populateCameraList);
|
||||
this.$refs.video?.addEventListener("loadedmetadata", this.onVideoResize);
|
||||
this.$refs.video?.addEventListener("resize", this.onVideoResize);
|
||||
if (this.cameraSupported && window.isSecureContext && this.autostart) {
|
||||
this.startCamera();
|
||||
} else {
|
||||
this.populateCameraList();
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopCamera();
|
||||
window.removeEventListener("resize", this.syncOverlaySize);
|
||||
window.removeEventListener("camera-disconnected", this.handleCameraDisconnected);
|
||||
window.removeEventListener("camera-reconnected", this.handleCameraReconnected);
|
||||
navigator.mediaDevices?.removeEventListener("devicechange", this.populateCameraList);
|
||||
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
|
||||
this.$refs.video?.removeEventListener("resize", this.onVideoResize);
|
||||
clearTimeout(this.clearOverlayTimer);
|
||||
this.decodeWorker.terminate();
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-wrap {
|
||||
position: relative;
|
||||
margin-bottom: .75rem;
|
||||
background: #000;
|
||||
border-radius: .35rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-wrap video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.video-wrap canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
196
frontend/src/components/CombinedFileField.vue
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<template>
|
||||
<drag-drop-file-source @input="addFiles">
|
||||
<h3 v-if="create">Create New</h3>
|
||||
<h3 v-else>Update</h3>
|
||||
<ul>
|
||||
<li v-for="file in without_images(files)" :key="file.id">
|
||||
{{ file.name }}
|
||||
</li>
|
||||
<li v-for="file in without_images(item_files)" :key="file.id">
|
||||
{{ file.name }}
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<div style="position: relative;">
|
||||
<div class="image-list">
|
||||
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id"
|
||||
@delete="deleteFile(file)">
|
||||
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
|
||||
</deletable-wrapper>
|
||||
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.data)" :key="file.id"
|
||||
@delete="deleteTempFile(file)">
|
||||
<img :alt="file.name" :src="'data:' + file.mime_type + ';base64,' + file.data"
|
||||
class="img-thumbnail border-info">
|
||||
</deletable-wrapper>
|
||||
<fs-file-source @input="addFiles">
|
||||
<div class="img-thumbnail btn btn-outline-primary">
|
||||
<b-icon-upload></b-icon-upload>
|
||||
</div>
|
||||
</fs-file-source>
|
||||
<camera-file-source @input="addFiles">
|
||||
<div class="img-thumbnail btn btn-outline-primary">
|
||||
<b-icon-camera></b-icon-camera>
|
||||
</div>
|
||||
</camera-file-source>
|
||||
<webcam-file-source @input="addFiles">
|
||||
<div class="img-thumbnail btn btn-outline-primary">
|
||||
<b-icon-camera-video></b-icon-camera-video>
|
||||
</div>
|
||||
</webcam-file-source>
|
||||
<label class="img-thumbnail btn btn-outline-primary" for="file-dropdown">
|
||||
<b-icon-plus></b-icon-plus>
|
||||
</label>
|
||||
</div>
|
||||
<input type="checkbox" id="file-dropdown" class="invisible-input">
|
||||
<div class="dropdown-menu" v-if="only_images(files).length > 0">
|
||||
<div class="image-list">
|
||||
<span v-for="file in only_images(files)" :key="file.id" @click="addExistingFiles([file])"
|
||||
style="cursor: pointer;">
|
||||
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</drag-drop-file-source>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.img-thumbnail {
|
||||
width: 95px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.img-thumbnail svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.invisible-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#file-dropdown:checked ~ .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-menu:hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#file-dropdown:checked ~ label {
|
||||
color: #fff;
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
#file-dropdown:checked ~ label:hover {
|
||||
color: var(--bs-primary);
|
||||
background-color: initial;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import DeletableWrapper from "@/components/DeletableWrapper.vue";
|
||||
//import DragDropFileSource from "@/../extras/components/inputs/DragDropFileSource.vue";
|
||||
//import CameraFileSource from "@/../extras/components/inputs/CameraFileSource.vue";
|
||||
//import FsFileSource from "@/../extras/components/inputs/FsFileSource.vue";
|
||||
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
|
||||
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
|
||||
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||
import WebcamFileSource from "@/components/inputs/WebcamFileSource.vue";
|
||||
|
||||
export default {
|
||||
name: "CombinedFileField",
|
||||
components: {
|
||||
WebcamFileSource,
|
||||
AuthenticatedImage,
|
||||
DeletableWrapper,
|
||||
DragDropFileSource,
|
||||
CameraFileSource,
|
||||
FsFileSource,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
item_files: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
item_id: {
|
||||
type: Number
|
||||
},
|
||||
create: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ["change"],
|
||||
computed: {
|
||||
...mapState(["files"]),
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchFiles", "pushFile", "deleteItemFile"]),
|
||||
async uploadFiles(files) {
|
||||
const jobs = files.map(async file => {
|
||||
return await this.pushFile({
|
||||
file: file,
|
||||
item_id: this.item_id
|
||||
});
|
||||
});
|
||||
return await Promise.all(jobs);
|
||||
},
|
||||
addFiles(files) {
|
||||
console.log("add files", files);
|
||||
const new_files = files.filter(file => !this.item_files.find(f => f.hash === file.hash));
|
||||
if (new_files.length === 0) {
|
||||
console.log("no new files");
|
||||
return;
|
||||
}
|
||||
if (!this.create) {
|
||||
this.uploadFiles(new_files).then((uploaded) => {
|
||||
this.$emit("change", [...this.item_files, ...uploaded]);
|
||||
})
|
||||
} else {
|
||||
this.$emit("change", [...this.item_files, ...new_files]);
|
||||
}
|
||||
|
||||
},
|
||||
addExistingFiles(files) {
|
||||
console.log("add existing files", files);
|
||||
const new_files = files.filter(file => !this.item_files.find(f => f.id === file.id));
|
||||
if (new_files.length === 0) {
|
||||
console.log("no new files");
|
||||
return;
|
||||
}
|
||||
this.$emit("change", [...this.item_files, ...new_files]);
|
||||
},
|
||||
deleteFile(file) {
|
||||
this.deleteItemFile({item_id: this.item_id, file_id: file.id}).then(() => {
|
||||
this.$emit("change", this.item_files.filter(f => f.id !== file.id));
|
||||
});
|
||||
},
|
||||
deleteTempFile(file) {
|
||||
this.$emit("change", this.item_files.filter(f => f.hash !== file.hash));
|
||||
},
|
||||
only_images(files) {
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
},
|
||||
without_images(files) {
|
||||
return files.filter(file => !file.mime_type.startsWith("image/"));
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchFiles();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
36
frontend/src/components/DeletableWrapper.vue
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<span style="position: relative; display: inline-block;">
|
||||
<div class="delete-button">
|
||||
<button class="btn btn-danger btn-sm" @click="triggerDelete">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</button>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.delete-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "DeletableWrapper",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
emits: ["delete"],
|
||||
methods: {
|
||||
triggerDelete() {
|
||||
this.$emit("delete");
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
</li>
|
||||
<li class="list-inline-item">
|
||||
<a class="text-muted"
|
||||
target="_blank" href="https://github.com/gr4yj3d1/toolshed">Sources</a>
|
||||
target="_blank" href="https://git.neulandlabor.de/j3d1/toolshed">Sources</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
324
frontend/src/components/LabelLayoutPreview.vue
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Label layout</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3 btn-group btn-group-toggle" role="group">
|
||||
<label class="btn btn-outline-secondary" :class="{active: activeTag === 'all'}">
|
||||
<input type="radio" autocomplete="off" value="all" v-model="activeTag"> All
|
||||
</label>
|
||||
<label v-for="tag in labelTags" :key="tag" class="btn btn-outline-secondary"
|
||||
:class="{active: activeTag === tag}">
|
||||
<input type="radio" autocomplete="off" :value="tag" v-model="activeTag"> {{ tagLabel(tag) }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="template-grid d-flex flex-wrap align-items-start">
|
||||
<div v-for="t in labelTemplates" :key="t.id" class="template-option d-flex flex-column text-center"
|
||||
:class="{'template-option-disabled': !isSelectable(t)}"
|
||||
:title="unavailableReason(t)"
|
||||
role="button" @click="isSelectable(t) && $emit('input', t.id)">
|
||||
<div class="template-thumb-wrap">
|
||||
<canvas :ref="el => setTemplateCanvasRef(t.id, el)"
|
||||
class="img-thumbnail template-thumb-canvas"
|
||||
:class="{'border-primary': value === t.id}"></canvas>
|
||||
<span v-if="t.id in failed" class="template-thumb-warning" :title="failed[t.id].message">
|
||||
{{ failed[t.id].short }}
|
||||
</span>
|
||||
<span v-else-if="t.id in warned" class="template-thumb-warning template-thumb-warning-soft"
|
||||
:title="warned[t.id].map(w => w.message).join(' ')">
|
||||
{{ warned[t.id].map(w => w.short).join(", ") }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="small"
|
||||
:class="{'fw-bold text-primary': value === t.id}">
|
||||
{{ t.name }}
|
||||
<span v-for="tag in t.tags" :key="tag" class="badge bg-secondary">{{ tagLabel(tag) }}</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{ t.description }}</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* This build pins Bootstrap 4 (no gap-* utilities, those are 5.1+) - hence plain CSS gap here. */
|
||||
.template-grid {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.template-option {
|
||||
width: 20rem;
|
||||
}
|
||||
|
||||
.template-option-disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.template-option .badge {
|
||||
font-size: .65rem;
|
||||
vertical-align: middle;
|
||||
margin-left: .35rem;
|
||||
}
|
||||
|
||||
.template-thumb-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.template-thumb-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
/* Canvas draws at whatever size fits its content (see redraw/drawFallbackLabel); object-fit
|
||||
scales it into the thumbnail box like it would an <img>, no manual zoom math needed. */
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger
|
||||
code than the tape allows"/"doesn't fit on this tape") or label.js's templateContent
|
||||
throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch).
|
||||
Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the
|
||||
reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the
|
||||
greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */
|
||||
.template-thumb-warning {
|
||||
position: absolute;
|
||||
top: .35rem;
|
||||
right: .35rem;
|
||||
max-width: calc(100% - .7rem);
|
||||
padding: .15rem .45rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(220, 53, 69, .9);
|
||||
color: #fff;
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, .15);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* Soft variant for a template that rendered fine but tripped one of label.js's scan-reliability
|
||||
thresholds (MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM) - still selectable,
|
||||
just flagged, so amber rather than the hard-failure badge's red. */
|
||||
.template-thumb-warning-soft {
|
||||
background: rgba(255, 193, 7, .9);
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import {
|
||||
drawLabel,
|
||||
drawFallbackLabel,
|
||||
preloadQrEncoder,
|
||||
preloadPixelFontRenderer,
|
||||
templateIsAvailable,
|
||||
templateContent
|
||||
} from "@/label.js";
|
||||
import {LABEL_TEMPLATES, LABEL_TAGS} from "@/label-layouts.js";
|
||||
|
||||
export default {
|
||||
name: "LabelLayoutPreview",
|
||||
props: {
|
||||
// Named content fields templates draw from (see label.js's buildLabelFields), kept in
|
||||
// sync by the parent. A field's absence (not just empty) means a template needing it is
|
||||
// unavailable.
|
||||
fields: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
// The selected template id.
|
||||
value: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
// Ids of the most-recently printed/downloaded templates, most recent first (see
|
||||
// Print.vue's rememberPrintedTemplate); bubbled to the front of the grid below.
|
||||
recentTemplateIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// The connected printer's tape/resolution (see label.js's tapeFromStatus), or null when
|
||||
// no printer is connected. When present, redraw() renders every thumbnail with drawLabel
|
||||
// at this tape's real dpi/printArea/length instead of drawFallbackLabel's fixed reference
|
||||
// - so the grid's px/mm-per-module numbers and any length-limit failures reflect what
|
||||
// would actually come out of the connected printer.
|
||||
tape: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
model: {
|
||||
prop: "value",
|
||||
event: "input"
|
||||
},
|
||||
emits: ["input"],
|
||||
data() {
|
||||
return {
|
||||
// Keyed by template id, holding the {short, message} error from its most recent
|
||||
// redraw() failure (e.g. text too long, or too many QR modules for the thumbnail's
|
||||
// fixed reference size - see label.js's snapQrToCrispSize/encodeQr). Absent, not just
|
||||
// falsy, for a template that last drew fine, so `t.id in failed` matches "has one".
|
||||
failed: {},
|
||||
// Keyed by template id, holding the array of {short, message} scan-reliability
|
||||
// warnings (see label.js's drawFallbackLabel) from its most recent successful
|
||||
// redraw() - present only when that render tripped
|
||||
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM.
|
||||
warned: {},
|
||||
// Keyed by template id, holding every QR-family leaf's raw {scale, moduleMm} from its
|
||||
// most recent successful redraw(), regardless of whether it warned - a debugging aid
|
||||
// shown unconditionally below each thumbnail.
|
||||
qrInfo: {},
|
||||
// Same idea as qrInfo, but every text leaf's raw {fontPx, fontMm} (see label.js's
|
||||
// checkTextSizes).
|
||||
textInfo: {},
|
||||
// Which LABEL_TAGS entry (or "all") the grid below is narrowed to.
|
||||
activeTag: "all",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
labelTags() {
|
||||
return LABEL_TAGS;
|
||||
},
|
||||
// LABEL_TEMPLATES with recentTemplateIds' entries pulled to the front (most recent
|
||||
// first, everything else following in its original order), narrowed to activeTag, then
|
||||
// with anything currently in `failed` (not printable - see redraw()'s catch) stably
|
||||
// sorted to the back regardless of recency/tag order, so real errors don't crowd out
|
||||
// layouts that actually work.
|
||||
labelTemplates() {
|
||||
const recent = this.recentTemplateIds
|
||||
.map(id => LABEL_TEMPLATES.find(t => t.id === id))
|
||||
.filter(Boolean);
|
||||
const recentIds = new Set(recent.map(t => t.id));
|
||||
const ordered = [...recent, ...LABEL_TEMPLATES.filter(t => !recentIds.has(t.id))];
|
||||
const filtered = this.activeTag === "all" ? ordered : ordered.filter(t => t.tags?.includes(this.activeTag));
|
||||
return [...filtered].sort((a, b) => (a.id in this.failed ? 1 : 0) - (b.id in this.failed ? 1 : 0));
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
fields() {
|
||||
this.redraw();
|
||||
},
|
||||
// Switching filters mounts fresh canvas elements for thumbnails hidden until now (see
|
||||
// labelTemplates); nextTick waits for those refs to land before drawing into them.
|
||||
activeTag() {
|
||||
this.$nextTick(() => this.redraw());
|
||||
},
|
||||
// Printer connect/disconnect/switch - every thumbnail's canvas already exists regardless
|
||||
// of tape (unlike Print.vue's own tape-fed preview), so no flush:'post' is needed here.
|
||||
tape() {
|
||||
this.redraw();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// "internal" -> "Internal", for both the filter buttons and each thumbnail's badge.
|
||||
tagLabel(tag) {
|
||||
return tag.charAt(0).toUpperCase() + tag.slice(1);
|
||||
},
|
||||
|
||||
setTemplateCanvasRef(id, el) {
|
||||
if (el) {
|
||||
this.templateCanvases[id] = el;
|
||||
} else {
|
||||
delete this.templateCanvases[id];
|
||||
}
|
||||
},
|
||||
|
||||
isAvailable(t) {
|
||||
return templateIsAvailable(t, this.fields);
|
||||
},
|
||||
|
||||
// Greyed out (and unclickable) either because a needed field isn't filled in yet
|
||||
// (isAvailable) or its content failed to render (see redraw()'s catch) - either way
|
||||
// there's nothing a click could select that would actually show anything.
|
||||
isSelectable(t) {
|
||||
return this.isAvailable(t) && !(t.id in this.failed);
|
||||
},
|
||||
|
||||
// The disabled thumbnail's tooltip - empty once it's selectable again.
|
||||
unavailableReason(t) {
|
||||
if (!this.isAvailable(t)) {
|
||||
return "Not available - fill in the fields this layout needs above.";
|
||||
}
|
||||
return this.failed[t.id]?.message ?? "";
|
||||
},
|
||||
|
||||
// .template-thumb-canvas's object-fit:contain scales the just-drawn bitmap into its fixed
|
||||
// CSS box (getBoundingClientRect, unlike canvas.width/height, reflects that box - object-fit
|
||||
// doesn't change it); when that's a magnification, switch to nearest-neighbor so the
|
||||
// print-accurate pixel edges stay crisp instead of blurring, same call Print.vue's own
|
||||
// fitZoom makes for its explicitly-sized preview.
|
||||
applyImageRendering(canvas) {
|
||||
const {width: boxWidth, height: boxHeight} = canvas.getBoundingClientRect();
|
||||
const scale = Math.min(boxWidth / canvas.width, boxHeight / canvas.height);
|
||||
canvas.style.imageRendering = scale >= 1 ? "pixelated" : "auto";
|
||||
},
|
||||
|
||||
// Live per-template thumbnails. Renders each with drawLabel at the connected printer's
|
||||
// real tape/resolution when one's connected (see the `tape` prop), so the numbers/errors
|
||||
// shown match what would actually print; falls back to drawFallbackLabel's fixed
|
||||
// reference size otherwise. Either way, still illustrative previews via CSS object-fit,
|
||||
// not the to-be-printed-accurate canvas Print.vue's own main preview is.
|
||||
redraw() {
|
||||
for (const t of LABEL_TEMPLATES) {
|
||||
const canvas = this.templateCanvases[t.id];
|
||||
if (!canvas) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const content = templateContent(t, this.fields);
|
||||
const {warnings, qrInfo, textInfo} = this.tape
|
||||
? drawLabel(canvas, this.tape, content, "along")
|
||||
: drawFallbackLabel(canvas, content, "along");
|
||||
this.applyImageRendering(canvas);
|
||||
delete this.failed[t.id];
|
||||
if (warnings.length) {
|
||||
this.warned[t.id] = warnings;
|
||||
} else {
|
||||
delete this.warned[t.id];
|
||||
}
|
||||
if (qrInfo.length) {
|
||||
this.qrInfo[t.id] = qrInfo;
|
||||
} else {
|
||||
delete this.qrInfo[t.id];
|
||||
}
|
||||
if (textInfo.length) {
|
||||
this.textInfo[t.id] = textInfo;
|
||||
} else {
|
||||
delete this.textInfo[t.id];
|
||||
}
|
||||
} catch (e) {
|
||||
// Grey the thumbnail out (see isSelectable/unavailableReason) rather than
|
||||
// leaving it blank - selecting a template that can't render here would only
|
||||
// hand Print.vue's own redraw the exact same failure.
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
this.failed[t.id] = {short: e.short ?? "error", message: e.message};
|
||||
delete this.warned[t.id];
|
||||
delete this.qrInfo[t.id];
|
||||
delete this.textInfo[t.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// Keyed by template id, populated by setTemplateCanvasRef() - a plain :ref="t.id" string
|
||||
// in v-for still gets Vue's refInFor array-collecting behavior despite distinct names per
|
||||
// iteration, turning this.$refs[t.id] into a one-element array; a function ref avoids that.
|
||||
this.templateCanvases = {};
|
||||
},
|
||||
async mounted() {
|
||||
// See Print.vue's mounted() - every thumbnail here has a qr/mqr/rmqr leaf and text drawn
|
||||
// through label.js's bitmap-font tiers, so nothing's worth drawing before both wasm
|
||||
// resources resolve.
|
||||
await Promise.all([preloadQrEncoder(), preloadPixelFontRenderer()]);
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
131
frontend/src/components/Messages.vue
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-icon dropdown-toggle" href="#" @click="toggleDropdown">
|
||||
<div class="position-relative">
|
||||
<b-icon-chat-left class="bi-valign-middle"></b-icon-chat-left>
|
||||
</div>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-lg dropdown-menu-right py-0" id="messagesDropdown">
|
||||
<div class="dropdown-menu-header">
|
||||
<div class="position-relative">
|
||||
4 New Messages
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group">
|
||||
<a href="#" class="list-group-item">
|
||||
<div class="row g-0 align-items-center">
|
||||
<div class="col-3">
|
||||
<img src="/assets/img/avatars/avatar-5.png"
|
||||
class="avatar img-fluid rounded-circle" alt="Vanessa Tucker">
|
||||
</div>
|
||||
<div class="col-9 pl-2">
|
||||
<div class="text-dark">Vanessa Tucker</div>
|
||||
<div class="text-muted small mt-1">Nam pretium turpis et arcu. Duis arcu
|
||||
tortor.
|
||||
</div>
|
||||
<div class="text-muted small mt-1">15m ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="list-group-item">
|
||||
<div class="row g-0 align-items-center">
|
||||
<div class="col-3">
|
||||
<img src="/assets/img/avatars/avatar-2.png"
|
||||
class="avatar img-fluid rounded-circle" alt="William Harris">
|
||||
</div>
|
||||
<div class="col-9 pl-2">
|
||||
<div class="text-dark">William Harris</div>
|
||||
<div class="text-muted small mt-1">Curabitur ligula sapien euismod
|
||||
vitae.
|
||||
</div>
|
||||
<div class="text-muted small mt-1">2h ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="list-group-item">
|
||||
<div class="row g-0 align-items-center">
|
||||
<div class="col-3">
|
||||
<img src="/assets/img/avatars/avatar-4.png"
|
||||
class="avatar img-fluid rounded-circle" alt="Christina Mason">
|
||||
</div>
|
||||
<div class="col-9 pl-2">
|
||||
<div class="text-dark">Christina Mason</div>
|
||||
<div class="text-muted small mt-1">Pellentesque auctor neque nec urna.
|
||||
</div>
|
||||
<div class="text-muted small mt-1">4h ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="#" class="list-group-item">
|
||||
<div class="row g-0 align-items-center">
|
||||
<div class="col-3">
|
||||
<img src="/assets/img/avatars/avatar-3.png"
|
||||
class="avatar img-fluid rounded-circle" alt="Sharon Lessman">
|
||||
</div>
|
||||
<div class="col-9 pl-2">
|
||||
<div class="text-dark">Sharon Lessman</div>
|
||||
<div class="text-muted small mt-1">Aenean tellus metus, bibendum sed,
|
||||
posuere ac,
|
||||
mattis non.
|
||||
</div>
|
||||
<div class="text-muted small mt-1">5h ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="dropdown-menu-footer">
|
||||
<a href="#" class="text-muted">Show all messages</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "Messages",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
top_messages() {
|
||||
return this.messages.slice(0, 4);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleDropdown() {
|
||||
closeAllDropdowns();
|
||||
document.getElementById("messagesDropdown").classList.toggle("show");
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
.nav-flag::after, .nav-icon::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-flag .feather, .nav-flag svg, .nav-icon .feather, .nav-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.nav-flag, .nav-icon {
|
||||
padding: .1rem .8rem;
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
color: #6c757d;
|
||||
transition: background .1s ease-in-out,color .1s ease-in-out;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/components/Notifications.vue
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<template>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-icon dropdown-toggle" href="#" @click="toggleDropdown">
|
||||
<div class="position-relative">
|
||||
<b-icon-bell class="bi-valign-middle"></b-icon-bell>
|
||||
<span :class="['indicator', notificationsColor(top_notifications)]">{{
|
||||
top_notifications.length
|
||||
}}</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="dropdown-menu dropdown-menu-lg dropdown-menu-right py-0" id="notificationsDropdown">
|
||||
<div class="dropdown-menu-header">
|
||||
{{ top_notifications.length }} New Notifications
|
||||
</div>
|
||||
<div class="list-group">
|
||||
<a href="#" class="list-group-item" v-for="notification in top_notifications" :key="notification.id">
|
||||
<div class="row g-0 align-items-center">
|
||||
<div class="col-2">
|
||||
<b-icon-exclamation-circle class="bi-valign-middle text-danger"
|
||||
v-if="notification.type === 'error'"></b-icon-exclamation-circle>
|
||||
<b-icon-info-circle class="bi-valign-middle text-info"
|
||||
v-else-if="notification.type === 'info'"></b-icon-info-circle>
|
||||
<b-icon-check-circle class="bi-valign-middle text-success"
|
||||
v-else-if="notification.type === 'success'"></b-icon-check-circle>
|
||||
<b-icon-bell class="bi-valign-middle text-warning"
|
||||
v-else-if="notification.type === 'warning'"></b-icon-bell>
|
||||
<b-icon-house class="bi-valign-middle text-primary"
|
||||
v-else-if="notification.type === 'login'"></b-icon-house>
|
||||
<b-icon-person-plus class="bi-valign-middle text-success"
|
||||
v-else-if="notification.type === 'friend'"></b-icon-person-plus>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div class="text-dark">{{ notification.title }}</div>
|
||||
<div class="text-muted small mt-1" v-if="notification.msg">{{ notification.msg }}</div>
|
||||
<div class="text-muted small mt-1">{{ humanizeTime(notification.time) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="dropdown-menu-footer">
|
||||
<a href="#" class="text-muted">Show all notifications</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapGetters, mapMutations} from 'vuex';
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import moment from 'moment';
|
||||
|
||||
export default {
|
||||
name: 'Notifications',
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
notifications: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
top_notifications() {
|
||||
return this.notifications.sort((a, b) => {
|
||||
return new Date(b.time) - new Date(a.time);
|
||||
}).slice(0, 8);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapMutations([]),
|
||||
humanizeTime(time) {
|
||||
return moment(time).fromNow();
|
||||
},
|
||||
notificationsColor(notifications) {
|
||||
if (notifications.length === 0) {
|
||||
return 'invisible';
|
||||
}
|
||||
if (notifications.filter(n => n.type === 'error').length > 0) {
|
||||
return 'bg-danger';
|
||||
}
|
||||
if (notifications.filter(n => n.type === 'warning').length > 0) {
|
||||
return 'bg-warning';
|
||||
}
|
||||
return 'bg-primary';
|
||||
},
|
||||
toggleDropdown() {
|
||||
closeAllDropdowns();
|
||||
document.getElementById('notificationsDropdown').classList.toggle('show');
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
.nav-flag::after, .nav-icon::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.nav-flag .feather, .nav-flag svg, .nav-icon .feather, .nav-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
115
frontend/src/components/PropertyBadge.vue
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<div class="input-group" :title="prettyDescription">
|
||||
<label class="input-group-text form-control-inline form-control-sm text-light border-dark bg-dark badge">
|
||||
{{ property.name }} =
|
||||
</label>
|
||||
<input type="number" min="0"
|
||||
class="form-control form-control-inline form-control-sm text-light border-dark bg-dark badge"
|
||||
placeholder="Enter value" @input.prevent="inputChange"
|
||||
:value="localValue" :style="inputStyle"/>
|
||||
<span class="input-group-text form-control-sm text-light border-dark bg-dark badge"
|
||||
v-if="property.unit_symbol" :title="(property.unit_name?property.unit_name:property.unit_symbol)">
|
||||
{{ property.unit_symbol }}
|
||||
</span>
|
||||
<span class="input-group-text form-control-sm text-light border-dark bg-dark badge pl-1">
|
||||
<b-icon-x-circle @click="removeProperty()"></b-icon-x-circle>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-control-inline {
|
||||
display: inline;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
width: initial;
|
||||
}
|
||||
|
||||
.form-control.badge {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.input-group-text.badge {
|
||||
padding: .3rem 0rem;
|
||||
}
|
||||
|
||||
.input-group .badge:first-child {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.input-group .badge:last-child {
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
input.badge {
|
||||
--width: 0em;
|
||||
min-height: calc(1.2rem);
|
||||
line-height: 1;
|
||||
min-width: calc(var(--width) + 1.35em + 18px);
|
||||
width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
input[type=number].badge {
|
||||
}
|
||||
|
||||
input.badge:empty {
|
||||
display: initial;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions, mapState} from "vuex";
|
||||
|
||||
export default {
|
||||
name: "PropertyBadge",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
localValue: this.property.value
|
||||
}
|
||||
},
|
||||
props: {
|
||||
property: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
property: {
|
||||
prop: "property",
|
||||
event: "modelChange"
|
||||
},
|
||||
computed: {
|
||||
prettyDescription() {
|
||||
var d = this.property.description ? this.property.description : this.property.name
|
||||
if (this.property.unit_name) {
|
||||
d += " (" + this.property.unit_name + ")"
|
||||
}
|
||||
return (d || "").replace(/<[^>]*>?/gm, '')
|
||||
},
|
||||
inputStyle() {
|
||||
const w = this.property.value ? Math.max(1, this.property.value.toString().length) : 1
|
||||
if (this.property.value === undefined) {
|
||||
console.log("No value for property", this.property)
|
||||
}
|
||||
return {
|
||||
"--width": w + "ex"
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
inputChange(event) {
|
||||
this.localValue = event.target.value
|
||||
this.$emit("modelChange", {...this.property, value: this.localValue})
|
||||
},
|
||||
removeProperty() {
|
||||
this.$emit("remove")
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
91
frontend/src/components/PropertyField.vue
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<div>
|
||||
<badge-select-field :value="this.splicedValue" :options="this.properties.map(p => p.name)" @addElement="addProperty"
|
||||
ref="badgeSelect">
|
||||
<template v-slot:default="{option, index}">
|
||||
<PropertyBadge :key="index"
|
||||
:property="option"
|
||||
@modelChange="setValue(index, $event)"
|
||||
@remove="removeProperty(index)"/>
|
||||
</template>
|
||||
</badge-select-field>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import PropertyBadge from "@/components/PropertyBadge.vue";
|
||||
//import BadgeSelectField from "@/../extras/components/inputs/BadgeSelectField.vue";
|
||||
import BadgeSelectField from "@/components/inputs/BadgeSelectField.vue";
|
||||
|
||||
|
||||
export default {
|
||||
name: "PropertyField",
|
||||
components: {
|
||||
BadgeSelectField,
|
||||
PropertyBadge,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
model: {
|
||||
prop: "value",
|
||||
event: "input"
|
||||
},
|
||||
computed: {
|
||||
...mapState(["properties"]),
|
||||
availableProperties() {
|
||||
if (!this.properties) return [];
|
||||
if (!this.value) return this.properties;
|
||||
return this.properties.filter(property => !this.value.map(p => p.name).includes(property.name));
|
||||
},
|
||||
localValue: {
|
||||
get() {
|
||||
if (!this.value) return [];
|
||||
return this.value;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("input", value);
|
||||
}
|
||||
},
|
||||
splicedValue() {
|
||||
if (!this.value || !this.properties) return [];
|
||||
return this.value.map(property => {
|
||||
return {
|
||||
...this.properties.find(p => p.name === property.name),
|
||||
value: property.value
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchProperties"]),
|
||||
addProperty(property) {
|
||||
if (property !== "") {
|
||||
this.localValue.push({name: property, value: 0});
|
||||
this.property = "";
|
||||
}
|
||||
},
|
||||
removeProperty(index) {
|
||||
this.localValue.splice(index, 1);
|
||||
},
|
||||
setValue(index, value) {
|
||||
if (value.target)
|
||||
return;
|
||||
this.localValue[index] = value;
|
||||
return true;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchProperties();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
63
frontend/src/components/SearchBox.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<form class="d-none d-sm-inline-block" @submit.prevent="search">
|
||||
<div class="input-group input-group-navbar">
|
||||
<input type="text" class="form-control" placeholder="Search…" aria-label="Search" v-model="query"
|
||||
ref="search-text"/>
|
||||
<button class="btn" type="button" @click.prevent="search">
|
||||
<b-icon-search class="bi-valign-middle"></b-icon-search>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "SearchBox",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
model: {
|
||||
prop: "query",
|
||||
event: "change"
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
query: ""
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
search() {
|
||||
if (this.query.length > 0)
|
||||
this.$router.push("/search/" + encodeURIComponent(this.query));
|
||||
else
|
||||
this.$refs["search-text"].focus();
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
query() {
|
||||
//emit event
|
||||
this.$emit("change", this.query);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//console.log(this.$route)
|
||||
//console.log(this.$route.params.query)
|
||||
this.query = decodeURIComponent(this.$route.params.query || encodeURIComponent(""));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
.input-group-navbar .btn, .input-group-navbar .form-control {
|
||||
height: calc(2.0875rem + 2px);
|
||||
background: #f7f7fc;
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
padding: .35rem .75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,7 +9,56 @@
|
|||
<li class="sidebar-header">
|
||||
Tools & Components
|
||||
</li>
|
||||
<li class="sidebar-item">
|
||||
<router-link to="/inventory" class="sidebar-link">
|
||||
<b-icon-archive class="bi-valign-middle"></b-icon-archive>
|
||||
<span class="align-middle">Inventory</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="sidebar-item">
|
||||
<router-link to="/storage-location" class="sidebar-link">
|
||||
<b-icon-boxes class="bi-valign-middle"></b-icon-boxes>
|
||||
<span class="align-middle">Storage Locations</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="sidebar-item">
|
||||
<router-link to="/workflows" class="sidebar-link">
|
||||
<b-icon-diagram3 class="bi-valign-middle"></b-icon-diagram3>
|
||||
<span class="align-middle">Workflows</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="sidebar-item">
|
||||
<router-link to="/files" class="sidebar-link">
|
||||
<b-icon-folder class="bi-valign-middle"></b-icon-folder>
|
||||
<span class="align-middle">Files</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<!--li class="sidebar-item">
|
||||
<router-link to="/admin" class="sidebar-link">
|
||||
<b-icon-gear class="bi-valign-middle"></b-icon-gear>
|
||||
<span class="align-middle">Admin</span>
|
||||
</router-link>
|
||||
</li-->
|
||||
<!--li class="sidebar-item">
|
||||
<router-link to="/swatch" class="sidebar-link">
|
||||
<b-icon-gear class="bi-valign-middle"></b-icon-gear>
|
||||
<span class="align-middle">Swatch</span>
|
||||
</router-link>
|
||||
</li-->
|
||||
<!--li class="sidebar-item">
|
||||
<router-link to="/print" class="sidebar-link">
|
||||
<b-icon-gear class="bi-valign-middle"></b-icon-gear>
|
||||
<span class="align-middle">Print</span>
|
||||
</router-link>
|
||||
</-->
|
||||
<!--li class="sidebar-item">
|
||||
<router-link to="/scan" class="sidebar-link">
|
||||
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
|
||||
<span class="align-middle">Scan</span>
|
||||
</router-link>
|
||||
</li-->
|
||||
</ul>
|
||||
<div class="sidebar-version" :title="versionTitle">{{ frontendCommit }}{{ backendCommit ? ' / ' + backendCommit : '' }}</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
|
@ -22,31 +71,37 @@ export default {
|
|||
components: {
|
||||
...BIcons
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
frontendCommit: __GIT_COMMIT__,
|
||||
backendCommit: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
versionTitle() {
|
||||
return `frontend build: ${this.frontendCommit}, backend: ${this.backendCommit || 'unknown'}`
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
try {
|
||||
const {commit} = await this.$store.dispatch('fetchBackendVersion')
|
||||
this.backendCommit = commit
|
||||
} catch (e) {
|
||||
console.error('could not fetch backend version', e)
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
min-width: 260px;
|
||||
max-width: 260px;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.sidebar, .sidebar-content {
|
||||
transition: margin-left .35s ease-in-out, left .35s ease-in-out, margin-right .35s ease-in-out, right .35s ease-in-out;
|
||||
background: #222e3c;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
.sidebar {
|
||||
min-width: 260px;
|
||||
max-width: 260px;
|
||||
direction: ltr
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +123,19 @@ export default {
|
|||
flex-grow: 1
|
||||
}
|
||||
|
||||
.sidebar-link, a.sidebar-link {
|
||||
display: block;
|
||||
padding: .625rem 1.625rem;
|
||||
font-weight: 400;
|
||||
transition: background .1s ease-in-out;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: #e9ecef80;
|
||||
background: #222e3c;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar-link i, .sidebar-link svg, a.sidebar-link i, a.sidebar-link svg {
|
||||
margin-right: .75rem;
|
||||
color: rgba(233, 236, 239, .5)
|
||||
|
|
@ -178,4 +246,11 @@ export default {
|
|||
font-size: .75rem;
|
||||
color: #ced4da
|
||||
}
|
||||
|
||||
.sidebar-version {
|
||||
padding: .5rem 1.5rem 1rem;
|
||||
font-size: .7rem;
|
||||
color: #ced4da4d;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
79
frontend/src/components/TagField.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<template>
|
||||
<badge-select-field :value="this.value" :options="this.tags" @addElement="addTag" ref="badgeSelect">
|
||||
<template v-slot:default="{option, index}">
|
||||
<span class="badge bg-dark" @click="removeTag(index)">
|
||||
{{ getNameFromHandle(option) }}
|
||||
</span>
|
||||
</template>
|
||||
</badge-select-field>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge {
|
||||
padding: .3rem .5rem;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
background-color: var(--bs-danger) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions, mapState, mapGetters} from "vuex";
|
||||
//import BadgeSelectField from "@/../extras/components/inputs/BadgeSelectField.vue";
|
||||
import BadgeSelectField from "@/components/inputs/BadgeSelectField.vue";
|
||||
|
||||
export default {
|
||||
name: "TagField",
|
||||
components: {
|
||||
BadgeSelectField,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
model: {
|
||||
prop: "value",
|
||||
event: "input"
|
||||
},
|
||||
computed: {
|
||||
...mapState(["tags"]),
|
||||
...mapGetters(["getNameFromHandle"]),
|
||||
availableTags() {
|
||||
return this.tags.filter(tag => !this.localValue.includes(tag));
|
||||
},
|
||||
localValue: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("input", value);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchTags"]),
|
||||
addTag(tag) {
|
||||
if (this.localValue.includes(tag)) {
|
||||
this.localValue.splice(this.localValue.indexOf(tag), 1);
|
||||
this.$refs.badgeSelect.resetSelection();
|
||||
} else if (this.availableTags.includes(tag)) {
|
||||
this.localValue.push(tag);
|
||||
this.$refs.badgeSelect.resetSelection();
|
||||
}else{
|
||||
// input does not match any tag
|
||||
}
|
||||
},
|
||||
removeTag(index) {
|
||||
this.localValue.splice(index, 1);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchTags();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
81
frontend/src/components/UserDropdown.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<template>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-icon dropdown-toggle d-inline-block d-sm-none" href="#" @click="toggleDropdown">
|
||||
<i class="align-middle" data-feather="settings"></i>
|
||||
<b-icon-person class="bi-valign-middle"></b-icon-person>
|
||||
</a>
|
||||
|
||||
<a class="nav-link dropdown-toggle d-none d-sm-inline-block" href="#" @click="toggleDropdown">
|
||||
<authenticated-image :src="profilePictureSrc" :owner="username" :alt="username" :title="username"
|
||||
img-class="avatar img-fluid rounded mr-1" :width="36" :height="36" fit="cover"/>
|
||||
|
||||
</a>
|
||||
|
||||
<div class="dropdown-menu dropdown-menu-right" id="userDropdown">
|
||||
<div class="dropdown-header">
|
||||
<span class="text-dark">
|
||||
{{ username }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<router-link to="/friends" class="dropdown-item">
|
||||
<b-icon-people class="bi-valign-middle mr-1"></b-icon-people>
|
||||
<span class="align-middle">Friends</span>
|
||||
</router-link>
|
||||
<router-link to="/groups" class="dropdown-item">
|
||||
<b-icon-people-fill class="bi-valign-middle mr-1"></b-icon-people-fill>
|
||||
<span class="align-middle">Groups</span>
|
||||
</router-link>
|
||||
<!--router-link to="/profile" class="dropdown-item">
|
||||
<b-icon-person class="bi-valign-middle mr-1"></b-icon-person>
|
||||
Profile
|
||||
</router-link-->
|
||||
<router-link to="/settings" class="dropdown-item">
|
||||
<b-icon-sliders class="bi-valign-middle mr-1"></b-icon-sliders>
|
||||
Settings &
|
||||
Privacy
|
||||
</router-link>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="#" @click="logout"> Log out</a>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from 'bootstrap-icons-vue';
|
||||
import {mapActions, mapMutations, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
|
||||
export default {
|
||||
name: "UserDropdown",
|
||||
components: {
|
||||
AuthenticatedImage,
|
||||
...BIcons
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchUserProfile']),
|
||||
...mapMutations(['logout']),
|
||||
toggleDropdown() {
|
||||
closeAllDropdowns();
|
||||
document.getElementById("userDropdown").classList.toggle("show");
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['user', 'user_profile']),
|
||||
username() {
|
||||
return this.user;
|
||||
},
|
||||
profilePictureSrc() {
|
||||
return this.user_profile?.profile_picture?.name || null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.user_profile) {
|
||||
this.fetchUserProfile();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
77
frontend/src/components/UserNameTag.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<div class="d-flex align-items-center">
|
||||
<authenticated-image v-if="profilePictureUrl"
|
||||
:src="profilePictureUrl"
|
||||
:owner="displayUsername"
|
||||
:alt="displayUsername"
|
||||
:title="displayUsername"
|
||||
img-class="rounded-circle mr-2"
|
||||
:width="width"
|
||||
:height="height"
|
||||
fit="cover"/>
|
||||
<span>{{ displayUsername }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import {mapGetters, mapActions} from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'UserNameTag',
|
||||
components: {
|
||||
AuthenticatedImage
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 32
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 32
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['allUserProfiles']),
|
||||
displayUsername() {
|
||||
return this.user.handle || this.user.owner || '';
|
||||
},
|
||||
profilePictureUrl() {
|
||||
// Check for explicitly set profile picture from allUserProfiles getter
|
||||
const storedProfile = this.allUserProfiles[this.displayUsername];
|
||||
if (storedProfile?.profile_picture?.name) {
|
||||
return storedProfile.profile_picture.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchFriendProfile']),
|
||||
fetchProfileIfNeeded() {
|
||||
// Fetch profile if not already in store
|
||||
if (!this.allUserProfiles[this.displayUsername] && this.displayUsername) {
|
||||
this.fetchFriendProfile({username: this.displayUsername});
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchProfileIfNeeded();
|
||||
},
|
||||
watch: {
|
||||
displayUsername() {
|
||||
this.fetchProfileIfNeeded();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
204
frontend/src/components/inputs/BadgeSelectField.vue
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<template>
|
||||
<div class="input-group">
|
||||
<div class="badge-list form-control form-control-lg" style="position: relative;">
|
||||
<span v-for="(option, index) in value" :key="index">
|
||||
<slot :option="option" :index="index"></slot>
|
||||
</span>
|
||||
<input type="text" class="invisible-input" v-model="textinput" ref="input"
|
||||
@keydown="keyDown">
|
||||
<ul class="dropdown-menu" style="position: absolute; top: 100%; left: 0; width: 100%; z-index: 100;"
|
||||
v-if="filteredOption.length > 0">
|
||||
<li v-for="(option, index) in filteredOption" :key="index"
|
||||
@mousedown="chooseOption(option)" @mouseover="selectOption(option)"
|
||||
:class="{chosen: localValue.includes(option), selected: selectedTag === option, 'dropdown-item': true}">
|
||||
{{ option }}
|
||||
<b>
|
||||
<b-icon-check class="checkmark"></b-icon-check>
|
||||
<b-icon-x class="cross"></b-icon-x>
|
||||
<b-icon-plus class="plus"></b-icon-plus>
|
||||
</b>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
.badge-list > * {
|
||||
flex: 0 1 auto;
|
||||
padding: .25rem 0;
|
||||
}
|
||||
|
||||
.badge-list > input.invisible-input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
input.invisible-input {
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #495057;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right .7rem center;
|
||||
background-size: 16px 12px;
|
||||
}
|
||||
|
||||
.invisible-input:focus ~ .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
display: none;
|
||||
overflow-x: auto;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.checkmark, .cross, .plus {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
background-color: var(--bs-primary);
|
||||
color: var(--bs-light);
|
||||
|
||||
& .plus {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item.chosen {
|
||||
& .checkmark {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item.chosen.selected {
|
||||
background-color: var(--bs-danger);
|
||||
color: var(--bs-light);
|
||||
|
||||
& .cross {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
& .checkmark, & .plus {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "BadgeSelectField",
|
||||
data() {
|
||||
return {
|
||||
textinput: "",
|
||||
selectedTag: ""
|
||||
}
|
||||
},
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
model: {
|
||||
prop: "value",
|
||||
event: "addElement"
|
||||
},
|
||||
computed: {
|
||||
availableTags() {
|
||||
return this.options.filter(option => !this.localValue.includes(option));
|
||||
},
|
||||
filteredOption() {
|
||||
return this.options.filter(option => option.includes(this.textinput) || option === this.selectedTag);
|
||||
},
|
||||
localValue: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(value) {
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
chooseOption(option) {
|
||||
this.$emit("addElement", option);
|
||||
},
|
||||
resetSelection() {
|
||||
this.textinput = "";
|
||||
this.selectedTag = null;
|
||||
},
|
||||
selectOption(option) {
|
||||
this.selectedTag = option;
|
||||
},
|
||||
keyDown(event) {
|
||||
const key = event.key;
|
||||
if (key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.chooseOption(this.selectedTag);
|
||||
this.$refs.input.blur();
|
||||
} else if (key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (this.selectedTag === "") {
|
||||
this.selectedTag = this.filteredOption[0];
|
||||
} else {
|
||||
const index = this.filteredOption.indexOf(this.selectedTag);
|
||||
if (index < this.filteredOption.length - 1) {
|
||||
this.selectedTag = this.filteredOption[index + 1];
|
||||
} else {
|
||||
this.selectedTag = this.filteredOption[0];
|
||||
}
|
||||
setTimeout(() => {
|
||||
const element = this.$el.querySelector(".selected");
|
||||
if (element) {
|
||||
element.scrollIntoView({block: "nearest"});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} else if (key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (this.selectedTag === "") {
|
||||
this.selectedTag = this.filteredOption[this.filteredOption.length - 1];
|
||||
} else {
|
||||
const index = this.filteredOption.indexOf(this.selectedTag);
|
||||
if (index > 0) {
|
||||
this.selectedTag = this.filteredOption[index - 1];
|
||||
} else {
|
||||
this.selectedTag = this.filteredOption[this.filteredOption.length - 1];
|
||||
}
|
||||
setTimeout(() => {
|
||||
const element = this.$el.querySelector(".selected");
|
||||
if (element) {
|
||||
element.scrollIntoView({block: "nearest"});
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
} else if (key === "ArrowRight") {
|
||||
if (event.target.selectionStart === this.textinput.length) {
|
||||
event.preventDefault();
|
||||
this.chooseOption(this.selectedTag);
|
||||
}
|
||||
} else if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.$refs.input.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
80
frontend/src/components/inputs/CameraFileSource.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div class="d-inline-block" v-if="show_camera">
|
||||
<label for="pictures">
|
||||
<slot></slot>
|
||||
</label>
|
||||
<input type="file" accept="image/*" multiple capture="camera" id="pictures" @change="loadFiles" class="d-none">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "CameraFileSource",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
emits: ["input"],
|
||||
data() {
|
||||
return {
|
||||
show_camera: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
loadFiles() {
|
||||
const files = document.getElementById("pictures").files;
|
||||
const jobs = [...files].map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const buffer = reader.result;
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
console.log(buffer)
|
||||
reject("Not an ArrayBuffer");
|
||||
return;
|
||||
}
|
||||
const data = new Uint8Array(buffer);
|
||||
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
var base64 = btoa(
|
||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||
);
|
||||
resolve({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime_type: file.type,
|
||||
data: base64,
|
||||
hash: hash,
|
||||
});
|
||||
};
|
||||
reader.onerror = (e) => {
|
||||
reject(e);
|
||||
};
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
});
|
||||
Promise.all(jobs).then((files) => {
|
||||
this.$emit("input", files)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//detect media api
|
||||
if ('capture' in document.createElement('input')) {
|
||||
console.log('capture supported.');
|
||||
this.show_camera = true;
|
||||
}
|
||||
|
||||
if (navigator.getUserMedia) {
|
||||
console.log('getUserMedia supported.');
|
||||
this.show_camera = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
92
frontend/src/components/inputs/DragDropFileSource.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div @dragenter.prevent="dragenter" @dragover.prevent="dragover" @dragleave.prevent="dragleave"
|
||||
@drop.prevent="drop" ref="dropzone">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dragover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "DragDropFileSource",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
emits: ["input"],
|
||||
methods: {
|
||||
dragenter(e) {
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.$refs.dropzone.classList.add("dragover")
|
||||
}else{
|
||||
console.log(e.dataTransfer.types) //TODO text/uri-list?
|
||||
}
|
||||
},
|
||||
dragover(e) {
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.$refs.dropzone.classList.add("dragover")
|
||||
}
|
||||
},
|
||||
dragleave(e) {
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.$refs.dropzone.classList.remove("dragover")
|
||||
}
|
||||
},
|
||||
drop(e) {
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
this.$refs.dropzone.classList.remove("dragover")
|
||||
const jobs = [...e.dataTransfer.files].map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let reader = new FileReader();
|
||||
reader.readAsArrayBuffer(file)
|
||||
reader.onloadend = async () => {
|
||||
const buffer = reader.result;
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
console.log(buffer)
|
||||
reject("Not an ArrayBuffer");
|
||||
return;
|
||||
}
|
||||
const data = new Uint8Array(buffer);
|
||||
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
var base64 = btoa(
|
||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||
);
|
||||
resolve({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime_type: file.type,
|
||||
data: base64,
|
||||
hash: hash,
|
||||
});
|
||||
}
|
||||
reader.onerror = (e) => {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
})
|
||||
Promise.all(jobs).then((files) => {
|
||||
this.$emit("input", files)
|
||||
console.log("drop", files)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
75
frontend/src/components/inputs/FsFileSource.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<div class="d-inline-block">
|
||||
<label :for="inputId">
|
||||
<slot></slot>
|
||||
</label>
|
||||
<input ref="fileInput" type="file" multiple :id="inputId" @change="loadFiles" class="d-none">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "FsFileSource",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
emits: ["input"],
|
||||
data() {
|
||||
return {
|
||||
inputId: `files-${this._uid}`
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
loadFiles() {
|
||||
const files = this.$refs.fileInput?.files;
|
||||
if (!files || files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const jobs = [...files].map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
const buffer = reader.result;
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
console.log(buffer)
|
||||
reject("Not an ArrayBuffer");
|
||||
return;
|
||||
}
|
||||
const data = new Uint8Array(buffer);
|
||||
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
var base64 = btoa(
|
||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||
);
|
||||
resolve({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime_type: file.type,
|
||||
data: base64,
|
||||
hash: hash,
|
||||
});
|
||||
};
|
||||
reader.onerror = (e) => {
|
||||
reject(e);
|
||||
};
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
});
|
||||
Promise.all(jobs).then((files) => {
|
||||
this.$emit("input", files)
|
||||
// Allow selecting the same file again to trigger change.
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = '';
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
168
frontend/src/components/inputs/PreferenceInput.vue
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<div class="input-group input-group-sm">
|
||||
<div v-if="inputType === 'checkbox'" class="input-group-prepend">
|
||||
<div :class="['input-group-text', 'bg-transparent', showBorder ? 'border-secondary' : 'border-0']">
|
||||
<input
|
||||
:id="id"
|
||||
ref="checkboxInput"
|
||||
type="checkbox"
|
||||
autocomplete="off"
|
||||
:checked="isChecked"
|
||||
:disabled="disabled"
|
||||
@change="handleCheckboxChange"
|
||||
>
|
||||
</div>
|
||||
<input type="text" class="form-control" value="" disabled style="display: none">
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-else-if="inputType === 'select'"
|
||||
:id="id"
|
||||
class="form-control form-control-sm"
|
||||
:value="displayValue"
|
||||
:disabled="disabled"
|
||||
@change="handleSelectChange"
|
||||
>
|
||||
<option value="">{{ disabled ? '-' : '(not set)' }}</option>
|
||||
<option v-for="option in schema.options || []" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
v-else-if="inputType === 'number'"
|
||||
:id="id"
|
||||
type="number"
|
||||
class="form-control form-control-sm"
|
||||
:value="displayValue"
|
||||
:disabled="disabled"
|
||||
:min="schema.validation_rules ? schema.validation_rules.min : undefined"
|
||||
:max="schema.validation_rules ? schema.validation_rules.max : undefined"
|
||||
:placeholder="disabled ? '' : '(not set)'"
|
||||
@input="handleNumberInput"
|
||||
>
|
||||
|
||||
<input
|
||||
v-else-if="inputType === 'text'"
|
||||
:id="id"
|
||||
type="text"
|
||||
class="form-control form-control-sm"
|
||||
:value="displayValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="disabled ? '' : '(not set)'"
|
||||
@input="handleTextInput"
|
||||
>
|
||||
|
||||
<textarea
|
||||
v-else-if="inputType === 'json'"
|
||||
:id="id"
|
||||
class="form-control form-control-sm"
|
||||
rows="3"
|
||||
style="font-family: monospace; font-size: 0.8rem"
|
||||
:value="jsonDisplayValue"
|
||||
:disabled="disabled"
|
||||
:placeholder="disabled ? '' : '(not set)'"
|
||||
@input="handleJsonInput"
|
||||
></textarea>
|
||||
|
||||
<div v-if="showClear" class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" title="Clear preference" @click="handleClear">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PreferenceInput',
|
||||
props: {
|
||||
id: {type: String, required: true},
|
||||
schema: {type: Object, required: true},
|
||||
value: {default: null},
|
||||
disabled: {type: Boolean, default: false},
|
||||
},
|
||||
emits: ['input', 'clear'],
|
||||
computed: {
|
||||
inputType() {
|
||||
const typeMapping = {
|
||||
boolean: 'checkbox',
|
||||
integer: 'number',
|
||||
float: 'number',
|
||||
enum: 'select',
|
||||
string: 'text',
|
||||
json: 'json',
|
||||
}
|
||||
return typeMapping[this.schema.type] || 'text'
|
||||
},
|
||||
isSet() {
|
||||
return this.value !== null && this.value !== undefined
|
||||
},
|
||||
isChecked() {
|
||||
return this.isSet ? Boolean(this.value) : false
|
||||
},
|
||||
showBorder() {
|
||||
return this.inputType === 'checkbox' && this.isSet && !this.disabled
|
||||
},
|
||||
showClear() {
|
||||
return !this.disabled && this.isSet
|
||||
},
|
||||
displayValue() {
|
||||
return this.value ?? ''
|
||||
},
|
||||
jsonDisplayValue() {
|
||||
if (this.value === null || this.value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : this.value
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.updateCheckboxIndeterminate()
|
||||
},
|
||||
updated() {
|
||||
this.updateCheckboxIndeterminate()
|
||||
},
|
||||
methods: {
|
||||
updateCheckboxIndeterminate() {
|
||||
if (this.inputType !== 'checkbox') {
|
||||
return
|
||||
}
|
||||
const input = this.$refs.checkboxInput
|
||||
if (input) {
|
||||
input.indeterminate = !this.isSet
|
||||
}
|
||||
},
|
||||
handleCheckboxChange(e) {
|
||||
this.$emit('input', e.target.checked)
|
||||
},
|
||||
handleSelectChange(e) {
|
||||
const val = e.target.value
|
||||
this.$emit('input', val === '' ? null : val)
|
||||
},
|
||||
handleNumberInput(e) {
|
||||
const raw = e.target.value
|
||||
const val = raw === '' ? null : parseFloat(raw)
|
||||
this.$emit('input', Number.isNaN(val) ? null : val)
|
||||
},
|
||||
handleTextInput(e) {
|
||||
const val = e.target.value
|
||||
this.$emit('input', val === '' ? null : val)
|
||||
},
|
||||
handleJsonInput(e) {
|
||||
const val = e.target.value.trim()
|
||||
if (val === '') {
|
||||
this.$emit('input', null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.$emit('input', JSON.parse(val))
|
||||
} catch {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
handleClear() {
|
||||
this.$emit('clear')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
237
frontend/src/components/inputs/WebcamFileSource.vue
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
<template>
|
||||
<div class="d-inline-block">
|
||||
<label @click="open">
|
||||
<slot></slot>
|
||||
</label>
|
||||
<div class="modal" :class="{'d-block': show_modal}" tabindex="-1">
|
||||
<div class="modal-dialog modal-fullscreen">
|
||||
<div class="modal-content p-2">
|
||||
<button type="button" class="btn-close position-absolute fixed-top m-2"
|
||||
style="right: 1rem; left: auto;"
|
||||
@click="close"></button>
|
||||
<div v-if="error" class="alert alert-danger" role="alert">
|
||||
{{ lastError }}
|
||||
</div>
|
||||
<div class="row" v-if="capturing && !streaming">
|
||||
<div class="spinner-grow text-danger mx-auto" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
v-if="!capturing && dataImage"
|
||||
class="img-fluid rounded mx-auto d-block w-75"
|
||||
:src="dataImage"
|
||||
alt="Image not available."
|
||||
/>
|
||||
<video
|
||||
ref="video"
|
||||
class="img-fluid rounded d-block img-preview w-75 mx-auto"
|
||||
:class="{'d-none': !capturing}"
|
||||
>
|
||||
Video stream not available.
|
||||
</video>
|
||||
<canvas ref="canvas" class="img-fluid d-none img-preview"/>
|
||||
<div class="position-absolute fixed-bottom text-center">
|
||||
<div class="btn-group shadow">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
:class="{'disabled': dataImage}"
|
||||
@click="(!dataImage) && captureVideoImage()">
|
||||
<b-icon-camera></b-icon-camera> Capture
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
:class="{'disabled': !dataImage}"
|
||||
@click="(dataImage) && retake()"
|
||||
>
|
||||
<b-icon-trash></b-icon-trash> Retake
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:class="{'disabled': !dataImage}"
|
||||
@click="(dataImage) && save()"
|
||||
>
|
||||
<b-icon-save></b-icon-save> Save
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
v-if="availableCameras.length > 1"
|
||||
v-model="selectedCameraId"
|
||||
class="form-select position-relative w-50 mx-auto mb-5 mt-2 shadow"
|
||||
aria-label="Select Camera Source">
|
||||
<option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera.deviceId">
|
||||
{{ cleanCameraLabel(camera.label) || `Camera ${availableCameras.indexOf(camera) + 1}` }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import cameraManager from '@/cameraManager.js';
|
||||
|
||||
export default {
|
||||
name: "WebcamFileSource",
|
||||
data: () => ({
|
||||
lastError: undefined,
|
||||
error: false,
|
||||
show_modal: false,
|
||||
availableCameras: [],
|
||||
localSelectedCameraId: null,
|
||||
capturing: false,
|
||||
streaming: false,
|
||||
dataImage: undefined
|
||||
}),
|
||||
emits: ['input'],
|
||||
computed: {
|
||||
selectedCameraId: {
|
||||
get() {
|
||||
return this.localSelectedCameraId || cameraManager.getSelectedCameraId();
|
||||
},
|
||||
set(value) {
|
||||
if (value) {
|
||||
this.localSelectedCameraId = value;
|
||||
this.switchCamera(value);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setupVideoStream(stream) {
|
||||
this.capturing = true;
|
||||
this.streaming = false;
|
||||
const video = this.$refs.video;
|
||||
if (!video) return;
|
||||
|
||||
video.srcObject = stream;
|
||||
video.play();
|
||||
video.oncanplay = () => {
|
||||
this.streaming = true;
|
||||
this.availableCameras = cameraManager.getAvailableCameras();
|
||||
this.localSelectedCameraId = cameraManager.getSelectedCameraId();
|
||||
};
|
||||
},
|
||||
async openStream(cameraId) {
|
||||
this.error = false;
|
||||
this.lastError = undefined;
|
||||
try {
|
||||
const stream = await cameraManager.openStream(cameraId);
|
||||
this.setupVideoStream(stream);
|
||||
} catch (err) {
|
||||
console.error('Failed to open camera stream:', err);
|
||||
if (err.name === "NotAllowedError") this.lastError = "Camera Permission Not Granted";
|
||||
else if (err.name === "NotReadableError") this.lastError = "Camera Hardware Error";
|
||||
else this.lastError = "Unknown Error";
|
||||
this.error = true;
|
||||
this.capturing = false;
|
||||
}
|
||||
},
|
||||
async switchCamera(cameraId) {
|
||||
if (!this.capturing) return;
|
||||
|
||||
this.streaming = false;
|
||||
try {
|
||||
const stream = await cameraManager.switchCamera(cameraId);
|
||||
if (stream) this.setupVideoStream(stream);
|
||||
} catch (err) {
|
||||
console.error('Failed to switch camera:', err);
|
||||
}
|
||||
},
|
||||
closeStream() {
|
||||
if (this.capturing) {
|
||||
cameraManager.closeStream();
|
||||
this.capturing = false;
|
||||
this.streaming = false;
|
||||
}
|
||||
},
|
||||
cleanCameraLabel(label) {
|
||||
if (!label) return '';
|
||||
const parts = label.split(':').map((p) => p.trim());
|
||||
if (parts.length === 2 && parts[0] === parts[1]) {
|
||||
return parts[0];
|
||||
}
|
||||
return label;
|
||||
},
|
||||
handleCameraDisconnected(event) {
|
||||
console.error('Camera disconnected:', event.detail);
|
||||
this.streaming = false;
|
||||
this.error = true;
|
||||
this.lastError = 'Camera Disconnected';
|
||||
},
|
||||
handleCameraReconnected() {
|
||||
const stream = cameraManager.getActiveStream();
|
||||
if (stream) {
|
||||
this.error = false;
|
||||
this.lastError = undefined;
|
||||
this.setupVideoStream(stream);
|
||||
}
|
||||
},
|
||||
async open() {
|
||||
this.show_modal = true;
|
||||
await this.openStream();
|
||||
},
|
||||
async close() {
|
||||
this.closeStream();
|
||||
this.show_modal = false;
|
||||
this.dataImage = undefined;
|
||||
this.error = false;
|
||||
this.lastError = undefined;
|
||||
},
|
||||
captureVideoImage() {
|
||||
const {video, canvas} = this.$refs;
|
||||
const context = canvas.getContext('2d');
|
||||
const {videoWidth, videoHeight} = video;
|
||||
canvas.width = videoWidth;
|
||||
canvas.height = videoHeight;
|
||||
context.drawImage(video, 0, 0, videoWidth, videoHeight);
|
||||
this.dataImage = canvas.toDataURL('image/jpeg', 0.5);
|
||||
this.closeStream();
|
||||
},
|
||||
retake() {
|
||||
this.dataImage = undefined;
|
||||
this.openStream();
|
||||
},
|
||||
async refreshCameraList() {
|
||||
await cameraManager.enumerateDevices();
|
||||
this.availableCameras = cameraManager.getAvailableCameras();
|
||||
},
|
||||
async save() {
|
||||
const mimeType = this.dataImage.split(';')[0].split(':')[1];
|
||||
const data = this.dataImage.split(',')[1];
|
||||
const raw_data = atob(data);
|
||||
// SHA-256 must match the backend's content hash for file identification. See docs/implementation.md#sha-256-file-hashing-must-match-the-backend.
|
||||
const bytes = Uint8Array.from(raw_data, c => c.charCodeAt(0));
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
const image = {
|
||||
name: hash.slice(0, 12) + ".jpg",
|
||||
size: raw_data.length,
|
||||
mime_type: mimeType,
|
||||
data: data,
|
||||
hash: hash,
|
||||
};
|
||||
this.$emit('input', [image]);
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('camera-disconnected', this.handleCameraDisconnected);
|
||||
window.addEventListener('camera-reconnected', this.handleCameraReconnected);
|
||||
navigator.mediaDevices?.addEventListener('devicechange', this.refreshCameraList);
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('camera-disconnected', this.handleCameraDisconnected);
|
||||
window.removeEventListener('camera-reconnected', this.handleCameraReconnected);
|
||||
navigator.mediaDevices?.removeEventListener('devicechange', this.refreshCameraList);
|
||||
this.closeStream();
|
||||
},
|
||||
}
|
||||
</script>
|
||||
123
frontend/src/components/workflow/README.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Workflow System
|
||||
|
||||
## Overview
|
||||
|
||||
Each workflow type (e.g. `foto-first-bulk-import`, `import-items`) is
|
||||
implemented by **one** Vue file that contains **all of its steps** and
|
||||
**its own metadata** (name, category, description, icons, duration, step
|
||||
list, initial payload). There is no separate per-step component registry and
|
||||
no parallel class hierarchy for metadata - a single flat catalog in
|
||||
`@/workflows.js` ties everything together.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Workflow components (`/components/workflow/workflows/*.vue`)
|
||||
|
||||
Each file is fully self-contained:
|
||||
|
||||
- **Metadata**: a static `meta: { id, name, category, description, icons,
|
||||
estimatedDuration, stepDefinitions, getInitialPayload() }` option on the
|
||||
component's options object, right alongside `name`/`components`/`props`.
|
||||
- **UI**: the same component implements every step internally, branching on
|
||||
the `step` prop (e.g. `v-if="step === '1'"`, `v-else-if="step === '2'"`, ...).
|
||||
|
||||
Currently implemented:
|
||||
- **FotoFirstBulkImportWorkflow.vue** - all 4 steps of `foto-first-bulk-import`:
|
||||
photo capture (camera + upload), image processing/compression, per-item
|
||||
detail entry, and import completion.
|
||||
- **BulkItemImportWorkflow.vue** - `import-items`' file upload (step 1) and
|
||||
item creation (step 6) with full UI; steps 2-5 and 7 render a generic
|
||||
placeholder using this same file's own `meta.stepDefinitions` (accessed via
|
||||
`this.$options.meta` inside the component) until their UI is built out.
|
||||
|
||||
### 2. The catalog (`@/workflows.js`)
|
||||
|
||||
A single flat array assembled from:
|
||||
- `implementedWorkflows` - workflows with a real component, built by
|
||||
importing the component and reading its static `.meta` property:
|
||||
`{ ...Component.meta, component: Component }`
|
||||
- `plannedWorkflows` - workflows that exist conceptually (so they show up in
|
||||
the catalog, can be started/tracked, and get a progress sidebar) but don't
|
||||
have a dedicated UI yet; these are plain metadata objects with
|
||||
`component: null`, defined directly in `workflows.js` since there's no
|
||||
component file to attach `meta` to
|
||||
|
||||
Exposed functions:
|
||||
- `getAllWorkflows()` - the full catalog, used by the `Workflows.vue` list
|
||||
- `getWorkflow(id)` - a single workflow definition by id
|
||||
- `getWorkflowComponent(id)` - the Vue component for a workflow, or `null`
|
||||
- `getWorkflowsByCategory(category)` / `getWorkflowCategories()`
|
||||
- `buildWorkflowApiPayload(workflow)` - builds the request body used to start
|
||||
a new `WorkflowInstance`, merging the common `workflow_config` block with
|
||||
the workflow's own `getInitialPayload()`
|
||||
- `serializeWorkflowPayload(workflow)` / `deserializeWorkflowPayload(workflow)`
|
||||
- JSON (de)serialization of `WorkflowInstance.payload`, also used by `store.js`
|
||||
|
||||
### 3. Usage in the views
|
||||
|
||||
`WorkflowDetail.vue`:
|
||||
```javascript
|
||||
import { getWorkflow, getWorkflowComponent } from '@/workflows.js';
|
||||
|
||||
workflowDefinition() {
|
||||
// `name` on the WorkflowInstance doubles as the workflow type identifier
|
||||
return getWorkflow(this.workflowInstance?.name);
|
||||
},
|
||||
workflowComponent() {
|
||||
return getWorkflowComponent(this.workflowInstance?.name);
|
||||
}
|
||||
```
|
||||
|
||||
The resolved component is rendered once via `<component :is="workflowComponent">`
|
||||
and receives the `step` prop on every step change - there's no need to swap
|
||||
components as the user navigates between steps of the same workflow.
|
||||
|
||||
`Workflows.vue` uses `getAllWorkflows()` for the catalog grid and
|
||||
`buildWorkflowApiPayload(workflow)` when starting a new instance.
|
||||
|
||||
### 4. Component contract
|
||||
|
||||
Every workflow component receives props `workflowInstance`, `step`, `payload`
|
||||
and emits `update`, `next`, `prev`, `complete`. `WorkflowDetail.vue` handles
|
||||
persisting payload updates (`handleStepUpdate`) and step navigation
|
||||
(`handleNextStep` / `handlePrevStep` / `completeWorkflow`), independent of
|
||||
which workflow is active.
|
||||
|
||||
### 5. Workflow progress sidebar
|
||||
|
||||
Step metadata (name/description, total step count) comes from
|
||||
`workflowDefinition.stepDefinitions` (the catalog entry, i.e. `Component.meta.stepDefinitions`),
|
||||
used to render the progress sidebar and step list regardless of whether a
|
||||
custom UI exists yet. `isStepCurrent(stepNumber)` / `isStepCompleted(stepNumber)` /
|
||||
`getStepClass(stepNumber)` in `WorkflowDetail.vue` drive the sidebar styling.
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding UI for a planned workflow
|
||||
1. Create `/components/workflow/workflows/MyWorkflow.vue`:
|
||||
- Add a `meta: {...}` option to the exported component (copy the matching
|
||||
entry out of `plannedWorkflows` in `workflows.js` and delete it there)
|
||||
- Implement every step internally (`v-if="step === '1'"`, etc.)
|
||||
2. In `workflows.js`, import the component and add
|
||||
`{ ...MyWorkflow.meta, component: MyWorkflow }` to `implementedWorkflows`
|
||||
|
||||
### Adding a brand new workflow type
|
||||
1. Create the `.vue` file as above with a unique `id`
|
||||
2. Add it to `implementedWorkflows` in `workflows.js` (if it has UI) or add a
|
||||
plain metadata object to `plannedWorkflows` (if it doesn't, yet)
|
||||
|
||||
### Adding steps to an existing workflow
|
||||
1. Add another `v-else-if="step === 'N'"` branch to the workflow's `.vue` file
|
||||
2. Add the step to that same file's `meta.stepDefinitions`
|
||||
3. Add any step-specific data/methods to the same file - no other file needs
|
||||
to change
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Single source of truth**: metadata and UI for a workflow live in one file
|
||||
2. **No indirection**: one array, no separate component registry to keep in sync
|
||||
3. **Cohesion**: all state and logic for a workflow's steps lives together
|
||||
4. **Extensibility**: new workflows are a single new file + one array entry
|
||||
5. **Consistency**: standardized props (`workflowInstance`, `step`, `payload`)
|
||||
and events (`update`, `next`, `prev`, `complete`) across all workflow components
|
||||
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<div class="backup-restore-workflow">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
|
||||
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step Progress -->
|
||||
<div class="progress mb-4" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Step Content -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center py-5">
|
||||
<b-icon-download class="text-muted mb-3" style="font-size: 3rem;"></b-icon-download>
|
||||
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
|
||||
<p class="text-muted">Implementation coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button
|
||||
v-if="parseInt(step) > 1"
|
||||
class="btn btn-outline-secondary"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
<button
|
||||
v-if="parseInt(step) < meta.stepDefinitions.length"
|
||||
class="btn btn-primary"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
Next
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-success"
|
||||
@click="$emit('complete')"
|
||||
>
|
||||
Complete
|
||||
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from 'bootstrap-icons-vue';
|
||||
|
||||
export default {
|
||||
name: 'BackupRestoreWorkflow',
|
||||
meta: {
|
||||
slug: 'backup-restore',
|
||||
title: 'Data Backup',
|
||||
category: 'System Maintenance',
|
||||
description: 'Create a comprehensive backup of your inventory and settings data.',
|
||||
icons: ['b-icon-gear', 'b-icon-download'],
|
||||
estimatedDuration: '15 minutes',
|
||||
stepDefinitions: [
|
||||
{ step: '1', name: 'Prepare Backup', description: 'Initialize backup process and verify system' },
|
||||
{ step: '2', name: 'Export Data', description: 'Export inventory, settings, and user data' },
|
||||
{ step: '3', name: 'Finalize Backup', description: 'Compress and store backup file' }
|
||||
],
|
||||
getInitialPayload() {
|
||||
return {
|
||||
backup_options: {
|
||||
include_inventory: true,
|
||||
include_settings: true,
|
||||
include_user_data: true,
|
||||
include_files: false,
|
||||
compression: true
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCurrentStepName() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.name : `Step ${this.step}`;
|
||||
},
|
||||
getCurrentStepDescription() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.description : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.backup-restore-workflow {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.step-navigation {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -0,0 +1,640 @@
|
|||
<template>
|
||||
<div class="bulk-item-import-workflow">
|
||||
<!-- Step 1: File Upload -->
|
||||
<div v-if="step === '1'" class="bulk-import-step-1">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">File Upload</h4>
|
||||
<p class="text-muted">Upload your CSV or Excel file containing item data for bulk import.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadError" class="alert alert-danger">{{ uploadError }}</div>
|
||||
|
||||
<!-- File Upload Area -->
|
||||
<div class="upload-section mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div v-if="!uploadedFile" class="upload-dropzone text-center py-5"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent
|
||||
@drop.prevent="handleFileDrop"
|
||||
:class="{ 'dragover': isDragOver }"
|
||||
@dragenter="isDragOver = true"
|
||||
@dragleave="isDragOver = false">
|
||||
|
||||
<b-icon-cloud-upload class="text-primary mb-3" style="font-size: 4rem;"></b-icon-cloud-upload>
|
||||
<h5 class="mb-3">Upload Your Data File</h5>
|
||||
<p class="text-muted mb-4">
|
||||
Drag and drop your CSV or Excel file here, or click to browse
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref="fileInput"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
@change="handleFileSelect"
|
||||
class="d-none"
|
||||
/>
|
||||
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary btn-lg me-2" @click="$refs.fileInput.click()">
|
||||
<b-icon-folder-open class="me-2"></b-icon-folder-open>
|
||||
Choose File
|
||||
</button>
|
||||
<button class="btn btn-outline-info" @click="downloadTemplate">
|
||||
<b-icon-download class="me-2"></b-icon-download>
|
||||
Download Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="supported-formats">
|
||||
<small class="text-muted">
|
||||
Supported formats: CSV (.csv), Excel (.xlsx, .xls)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Info Display -->
|
||||
<div v-else class="file-info">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<b-icon-file-earmark-spreadsheet class="text-success me-3" style="font-size: 2rem;"></b-icon-file-earmark-spreadsheet>
|
||||
<div>
|
||||
<h6 class="mb-1">{{ uploadedFile.name }}</h6>
|
||||
<small class="text-muted">
|
||||
{{ formatFileSize(uploadedFile.size) }} •
|
||||
{{ getFileType(uploadedFile.name) }} •
|
||||
Uploaded {{ formatDateTime(uploadTime) }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-outline-danger btn-sm" @click="removeFile">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File Analysis Results -->
|
||||
<div v-if="fileAnalysis" class="file-analysis">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-primary mb-1">{{ fileAnalysis.totalRows }}</h4>
|
||||
<small class="text-muted">Total Rows</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-info mb-1">{{ fileAnalysis.totalColumns }}</h4>
|
||||
<small class="text-muted">Columns</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-success mb-1">{{ fileAnalysis.validRows }}</h4>
|
||||
<small class="text-muted">Valid Rows</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="text-center">
|
||||
<h4 class="text-warning mb-1">{{ fileAnalysis.errorRows }}</h4>
|
||||
<small class="text-muted">Issues Found</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Processing Status -->
|
||||
<div v-if="processingFile" class="processing-status mb-4">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="spinner-border text-primary me-3" role="status"></div>
|
||||
<div>
|
||||
<h6 class="mb-1">Processing file...</h6>
|
||||
<p class="text-muted mb-0">{{ processingStatus }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column Mapping -->
|
||||
<div v-if="fileAnalysis && !processingFile" class="column-mapping mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Column Mapping</h6>
|
||||
<small class="text-muted">Map your file columns to system fields</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div v-for="field in requiredFields" :key="field.key" class="col-md-6 mb-3">
|
||||
<label class="form-label">
|
||||
{{ field.label }}
|
||||
<span v-if="field.required" class="text-danger">*</span>
|
||||
</label>
|
||||
<select class="form-select" v-model="columnMapping[field.key]">
|
||||
<option value="">Select column...</option>
|
||||
<option v-for="column in detectedColumns" :key="column" :value="column">
|
||||
{{ column }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Preview -->
|
||||
<div v-if="previewData.length > 0" class="data-preview mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Data Preview</h6>
|
||||
<small class="text-muted">First 5 rows of your data</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in detectedColumns" :key="column">{{ column }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in previewData.slice(0, 5)" :key="index">
|
||||
<td v-for="column in detectedColumns" :key="column">
|
||||
{{ row[column] || '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<div></div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="proceedFromStep1"
|
||||
:disabled="!canProceedFromStep1"
|
||||
>
|
||||
Next: Parse Data
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Import Items -->
|
||||
<div v-else-if="step === '6'" class="bulk-import-step-6">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Import Items</h4>
|
||||
<p class="text-muted">
|
||||
Create inventory items from the rows parsed in the File Upload step.
|
||||
The file itself was never sent to the server - only these structured
|
||||
item rows are.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!importResults" class="card mb-4">
|
||||
<div class="card-body text-center py-4">
|
||||
<p class="mb-3">
|
||||
<strong>{{ items.length }}</strong> item(s) ready to import.
|
||||
</p>
|
||||
<button class="btn btn-primary" :disabled="importing || items.length === 0" @click="runImport">
|
||||
<span v-if="importing" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Import {{ items.length }} item(s)
|
||||
</button>
|
||||
<div v-if="importError" class="alert alert-danger mt-3 mb-0">{{ importError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-2">Import complete</h6>
|
||||
<p class="mb-2">
|
||||
<span class="badge bg-success me-2">{{ importResults.created_count }} created</span>
|
||||
<span v-if="importResults.errors && importResults.errors.length" class="badge bg-warning text-dark">
|
||||
{{ importResults.errors.length }} issue(s)
|
||||
</span>
|
||||
</p>
|
||||
<ul v-if="importResults.errors && importResults.errors.length" class="small text-muted mb-0">
|
||||
<li v-for="(err, index) in importResults.errors" :key="index">{{ err }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="!importResults" @click="$emit('next')">
|
||||
Next: Generate Report
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback for not-yet-implemented steps (2-5, 7) -->
|
||||
<div v-else class="text-center py-5">
|
||||
<b-icon-gear class="text-muted mb-3" style="font-size: 3rem;"></b-icon-gear>
|
||||
<h5 class="text-muted">{{ currentStepDefinition?.name || `Step ${step}` }}</h5>
|
||||
<p class="text-muted">{{ currentStepDefinition?.description || 'This step is in progress.' }}</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-outline-secondary me-2" @click="$emit('prev')">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="$emit('next')">
|
||||
Next
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import { mapActions } from 'vuex';
|
||||
|
||||
// Implements every step of 'import-items'; steps 2-5,7 use a generic placeholder. See docs/implementation.md#bulk-item-import-workflow.
|
||||
export default {
|
||||
name: 'BulkItemImportWorkflow',
|
||||
// Co-located workflow metadata, single source of truth per workflow type. See docs/implementation.md#workflow-meta-co-location.
|
||||
meta: {
|
||||
slug: 'import-items',
|
||||
title: 'Bulk Item Import',
|
||||
category: 'Data Management',
|
||||
description: 'Import multiple inventory items from CSV or Excel files with validation.',
|
||||
icons: ['b-icon-upload', 'b-icon-file-earmark-spreadsheet', 'b-icon-list-check'],
|
||||
estimatedDuration: '20-60 minutes',
|
||||
stepDefinitions: [
|
||||
{ step: '1', name: 'File Upload', description: 'Upload CSV or Excel file' },
|
||||
{ step: '2', name: 'Parse Data', description: 'Parse and analyze file contents' },
|
||||
{ step: '3', name: 'Validate Format', description: 'Validate data format and structure' },
|
||||
{ step: '4', name: 'Data Validation', description: 'Validate individual item data' },
|
||||
{ step: '5', name: 'Conflict Resolution', description: 'Resolve any data conflicts' },
|
||||
{ step: '6', name: 'Import Items', description: 'Import validated items into system' },
|
||||
{ step: '7', name: 'Generate Report', description: 'Generate import summary report' }
|
||||
],
|
||||
getInitialPayload() {
|
||||
return {
|
||||
import_options: {
|
||||
file_type: null,
|
||||
skip_duplicates: true,
|
||||
update_existing: false,
|
||||
validate_required_fields: true
|
||||
},
|
||||
mapping: {},
|
||||
validation_results: []
|
||||
};
|
||||
}
|
||||
},
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Step 1: file upload
|
||||
uploadedFile: null,
|
||||
uploadTime: null,
|
||||
processingFile: false,
|
||||
processingStatus: '',
|
||||
isDragOver: false,
|
||||
uploadError: null,
|
||||
fileAnalysis: null,
|
||||
detectedColumns: [],
|
||||
parsedRows: [],
|
||||
previewData: [],
|
||||
columnMapping: {},
|
||||
requiredFields: [
|
||||
{ key: 'name', label: 'Item Name', required: true },
|
||||
{ key: 'category', label: 'Category', required: false },
|
||||
{ key: 'quantity', label: 'Quantity', required: false },
|
||||
{ key: 'unit', label: 'Unit', required: false },
|
||||
{ key: 'description', label: 'Description', required: false },
|
||||
{ key: 'location', label: 'Storage Location', required: false },
|
||||
{ key: 'purchase_price', label: 'Purchase Price', required: false },
|
||||
{ key: 'estimated_value', label: 'Estimated Value', required: false }
|
||||
],
|
||||
|
||||
// Step 6: import items
|
||||
importing: false,
|
||||
importError: null,
|
||||
importResults: this.payload.import_results || null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
canProceedFromStep1() {
|
||||
return this.uploadedFile && this.fileAnalysis && this.columnMapping.name;
|
||||
},
|
||||
items() {
|
||||
return this.payload.items || [];
|
||||
},
|
||||
currentStepDefinition() {
|
||||
return this.$options.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// Step 1: load existing data if resuming
|
||||
if (this.payload.file_name) {
|
||||
this.uploadTime = this.payload.upload_time;
|
||||
this.fileAnalysis = this.payload.file_analysis;
|
||||
this.detectedColumns = this.payload.detected_columns || [];
|
||||
this.parsedRows = this.payload.parsed_rows || [];
|
||||
this.previewData = this.payload.preview_data || [];
|
||||
this.columnMapping = this.payload.column_mapping || {};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['createInventoryItem']),
|
||||
|
||||
// --- Step 1: File upload ---
|
||||
handleFileDrop(event) {
|
||||
this.isDragOver = false;
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
this.processFile(files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
this.processFile(files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
async processFile(file) {
|
||||
this.uploadError = null;
|
||||
if (!this.isValidFileType(file)) {
|
||||
this.uploadError = 'Please upload a CSV or Excel file (.csv, .xlsx, .xls)';
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadedFile = file;
|
||||
this.uploadTime = new Date().toISOString();
|
||||
this.processingFile = true;
|
||||
this.processingStatus = 'Reading file...';
|
||||
|
||||
try {
|
||||
await this.analyzeFile(file);
|
||||
this.processingStatus = 'Analyzing data structure...';
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
this.processingStatus = 'Generating preview...';
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
this.updateStep1Payload();
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error);
|
||||
this.uploadError = 'Error processing file. Please try again.';
|
||||
this.removeFile();
|
||||
} finally {
|
||||
this.processingFile = false;
|
||||
this.processingStatus = '';
|
||||
}
|
||||
},
|
||||
|
||||
async analyzeFile(file) {
|
||||
// Parsing happens entirely client-side; the backend only ever sees the resulting `items` list.
|
||||
if (file.name.endsWith('.csv')) {
|
||||
const text = await file.text();
|
||||
this.parsedRows = this.parseCsv(text);
|
||||
} else {
|
||||
// Mock implementation; a real Excel parser would use a library like SheetJS.
|
||||
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
|
||||
this.parsedRows = [
|
||||
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
|
||||
{ Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' }
|
||||
];
|
||||
}
|
||||
|
||||
this.previewData = this.parsedRows.slice(0, 5);
|
||||
this.fileAnalysis = {
|
||||
totalRows: this.parsedRows.length,
|
||||
totalColumns: this.detectedColumns.length,
|
||||
validRows: this.parsedRows.length,
|
||||
errorRows: 0
|
||||
};
|
||||
|
||||
this.autoMapColumns();
|
||||
},
|
||||
|
||||
// Simple CSV parser: no external dependency, entirely client-side.
|
||||
parseCsv(text) {
|
||||
const lines = text.split('\n').filter(line => line.trim());
|
||||
if (lines.length === 0) {
|
||||
this.detectedColumns = [];
|
||||
return [];
|
||||
}
|
||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||
this.detectedColumns = headers;
|
||||
return lines.slice(1).map(line => {
|
||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||
const row = {};
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || '';
|
||||
});
|
||||
return row;
|
||||
});
|
||||
},
|
||||
|
||||
// Converts parsed rows + column mapping into item dicts for the "Import Items" step.
|
||||
buildItemsFromMapping() {
|
||||
return this.parsedRows.map(row => ({
|
||||
name: this.columnMapping.name ? row[this.columnMapping.name] : '',
|
||||
description: this.columnMapping.description ? row[this.columnMapping.description] : '',
|
||||
category: this.columnMapping.category ? row[this.columnMapping.category] : '',
|
||||
quantity: this.columnMapping.quantity ? row[this.columnMapping.quantity] : undefined,
|
||||
})).filter(item => item.name);
|
||||
},
|
||||
|
||||
autoMapColumns() {
|
||||
const mapping = {};
|
||||
|
||||
this.detectedColumns.forEach(column => {
|
||||
const lowerColumn = column.toLowerCase();
|
||||
|
||||
if (lowerColumn.includes('name') || lowerColumn.includes('item')) {
|
||||
mapping.name = column;
|
||||
} else if (lowerColumn.includes('category') || lowerColumn.includes('type')) {
|
||||
mapping.category = column;
|
||||
} else if (lowerColumn.includes('quantity') || lowerColumn.includes('qty')) {
|
||||
mapping.quantity = column;
|
||||
} else if (lowerColumn.includes('unit')) {
|
||||
mapping.unit = column;
|
||||
} else if (lowerColumn.includes('description') || lowerColumn.includes('desc')) {
|
||||
mapping.description = column;
|
||||
} else if (lowerColumn.includes('location') || lowerColumn.includes('storage')) {
|
||||
mapping.location = column;
|
||||
} else if (lowerColumn.includes('price') && lowerColumn.includes('purchase')) {
|
||||
mapping.purchase_price = column;
|
||||
} else if (lowerColumn.includes('value') || lowerColumn.includes('price')) {
|
||||
mapping.estimated_value = column;
|
||||
}
|
||||
});
|
||||
|
||||
this.columnMapping = mapping;
|
||||
},
|
||||
|
||||
isValidFileType(file) {
|
||||
const validTypes = ['.csv', '.xlsx', '.xls'];
|
||||
return validTypes.some(type => file.name.toLowerCase().endsWith(type));
|
||||
},
|
||||
|
||||
getFileType(filename) {
|
||||
if (filename.endsWith('.csv')) return 'CSV';
|
||||
if (filename.endsWith('.xlsx') || filename.endsWith('.xls')) return 'Excel';
|
||||
return 'Unknown';
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
},
|
||||
|
||||
formatDateTime(dateString) {
|
||||
return new Date(dateString).toLocaleString();
|
||||
},
|
||||
|
||||
removeFile() {
|
||||
this.uploadedFile = null;
|
||||
this.uploadTime = null;
|
||||
this.fileAnalysis = null;
|
||||
this.detectedColumns = [];
|
||||
this.parsedRows = [];
|
||||
this.previewData = [];
|
||||
this.columnMapping = {};
|
||||
this.updateStep1Payload();
|
||||
},
|
||||
|
||||
downloadTemplate() {
|
||||
const headers = ['Name', 'Category', 'Quantity', 'Unit', 'Description', 'Location', 'Purchase Price', 'Estimated Value'];
|
||||
const sampleData = [
|
||||
['Hammer', 'Tools', '1', 'piece', 'Claw hammer for general use', 'Toolbox A', '25.99', '30.00'],
|
||||
['Screws', 'Hardware', '100', 'pack', 'Wood screws 2 inch', 'Storage Bin 3', '12.50', '15.00']
|
||||
];
|
||||
|
||||
const csvContent = [headers, ...sampleData]
|
||||
.map(row => row.map(field => `"${field}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'inventory_import_template.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
updateStep1Payload() {
|
||||
this.$emit('update', {
|
||||
file_name: this.uploadedFile?.name || null,
|
||||
upload_time: this.uploadTime,
|
||||
file_analysis: this.fileAnalysis,
|
||||
detected_columns: this.detectedColumns,
|
||||
parsed_rows: this.parsedRows,
|
||||
preview_data: this.previewData,
|
||||
column_mapping: this.columnMapping,
|
||||
items: this.buildItemsFromMapping()
|
||||
});
|
||||
},
|
||||
|
||||
proceedFromStep1() {
|
||||
if (!this.canProceedFromStep1) {
|
||||
this.uploadError = 'Please upload a file and map the required Name column before proceeding.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadError = null;
|
||||
this.updateStep1Payload();
|
||||
this.$emit('next');
|
||||
},
|
||||
|
||||
// --- Step 6: Import items ---
|
||||
async runImport() {
|
||||
this.importing = true;
|
||||
this.importError = null;
|
||||
const created_item_ids = [];
|
||||
const errors = [];
|
||||
for (const [index, row] of this.items.entries()) {
|
||||
if (!row.name) {
|
||||
errors.push(`Row ${index + 1}: missing required "name" field, skipped.`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const created = await this.createInventoryItem({
|
||||
name: row.name,
|
||||
description: row.description || '',
|
||||
owned_quantity: parseInt(row.quantity) || 1,
|
||||
...(row.category ? {category: row.category} : {})
|
||||
});
|
||||
created_item_ids.push(created.id);
|
||||
} catch (err) {
|
||||
console.error('Failed to create item:', row, err);
|
||||
errors.push(`Row ${index + 1} ("${row.name}"): failed to create.`);
|
||||
}
|
||||
}
|
||||
this.importResults = {created_item_ids, created_count: created_item_ids.length, errors};
|
||||
this.$emit('update', {import_results: this.importResults});
|
||||
this.importing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-dropzone {
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-dropzone:hover,
|
||||
.upload-dropzone.dragover {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-analysis {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<template>
|
||||
<div class="expiry-check-workflow">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
|
||||
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step Progress -->
|
||||
<div class="progress mb-4" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Step Content -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center py-5">
|
||||
<b-icon-clock-history class="text-muted mb-3" style="font-size: 3rem;"></b-icon-clock-history>
|
||||
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
|
||||
<p class="text-muted">Implementation coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button
|
||||
v-if="parseInt(step) > 1"
|
||||
class="btn btn-outline-secondary"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
<button
|
||||
v-if="parseInt(step) < meta.stepDefinitions.length"
|
||||
class="btn btn-primary"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
Next
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-success"
|
||||
@click="$emit('complete')"
|
||||
>
|
||||
Complete
|
||||
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from 'bootstrap-icons-vue';
|
||||
|
||||
export default {
|
||||
name: 'ExpiryCheckWorkflow',
|
||||
meta: {
|
||||
slug: 'expiry-check',
|
||||
title: 'Expiry Date Check',
|
||||
category: 'Quality Control',
|
||||
description: 'Identify and handle items approaching or past their expiry dates.',
|
||||
icons: ['b-icon-clock-history', 'b-icon-exclamation-triangle'],
|
||||
estimatedDuration: '45 minutes',
|
||||
stepDefinitions: [
|
||||
{ step: '1', name: 'Scan Expiry Dates', description: 'Check all items for expiry information' },
|
||||
{ step: '2', name: 'Identify Critical Items', description: 'Find expired and soon-to-expire items' },
|
||||
{ step: '3', name: 'Assess Item Condition', description: 'Evaluate condition of critical items' },
|
||||
{ step: '4', name: 'Generate Action Plan', description: 'Create disposal or usage recommendations' },
|
||||
{ step: '5', name: 'Execute Actions', description: 'Implement recommended actions' },
|
||||
{ step: '6', name: 'Update Records', description: 'Update item statuses and records' }
|
||||
],
|
||||
getInitialPayload() {
|
||||
return {
|
||||
check_parameters: {
|
||||
warning_days: 30,
|
||||
include_no_expiry: false,
|
||||
categories: []
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCurrentStepName() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.name : `Step ${this.step}`;
|
||||
},
|
||||
getCurrentStepDescription() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.description : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.expiry-check-workflow {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.step-navigation {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -0,0 +1,741 @@
|
|||
<template>
|
||||
<div class="foto-first-workflow">
|
||||
<!-- Step 1: Photo Capture -->
|
||||
<div v-if="step === '1'" class="foto-first-step-1">
|
||||
|
||||
<div class="staging-area mb-4">
|
||||
<drag-drop-file-source @input="addStagedFiles">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<b-icon-upload class="text-primary mb-2" style="font-size: 2.5rem;"></b-icon-upload>
|
||||
<p class="text-muted small mb-3">Drag and drop photos here, or add them below</p>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<fs-file-source @input="addStagedFiles">
|
||||
<span class="btn btn-outline-success">
|
||||
<b-icon-upload class="me-1"></b-icon-upload>
|
||||
Upload Files
|
||||
</span>
|
||||
</fs-file-source>
|
||||
<camera-file-source @input="addStagedFiles">
|
||||
<span class="btn btn-outline-primary">
|
||||
<b-icon-camera class="me-1"></b-icon-camera>
|
||||
Camera
|
||||
</span>
|
||||
</camera-file-source>
|
||||
<webcam-file-source @input="addStagedFiles">
|
||||
<span class="btn btn-outline-primary">
|
||||
<b-icon-camera-video class="me-1"></b-icon-camera-video>
|
||||
Webcam
|
||||
</span>
|
||||
</webcam-file-source>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</drag-drop-file-source>
|
||||
</div>
|
||||
|
||||
<!-- Photo Gallery -->
|
||||
<div v-if="photos.length > 0" class="photo-gallery mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Captured Photos ({{ photos.length }})</h6>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="clearAllPhotos">
|
||||
<b-icon-trash class="me-1"></b-icon-trash>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||
<div class="card">
|
||||
<div class="photo-thumb-wrap">
|
||||
<transition name="photo-wipe">
|
||||
<img v-if="photo.dataUrl && !photo.uploaded" key="local" :src="photo.dataUrl"
|
||||
class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
|
||||
<authenticated-image v-else key="remote" :src="thumbnailPathForHash(photo.hash)"
|
||||
:owner="user" img-class="card-img-top photo-thumbnail"
|
||||
:alt="`Photo ${index + 1}`"/>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Photo {{ index + 1 }}</small>
|
||||
<button class="btn btn-sm btn-outline-danger" @click="removePhoto(index)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<div></div>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="proceedFromStep1"
|
||||
:disabled="photos.length === 0"
|
||||
>
|
||||
Next: Data Presets
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Data Presets -->
|
||||
<div v-else-if="step === '2'" class="foto-first-step-2">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Data Presets</h4>
|
||||
<p class="text-muted">
|
||||
Set default values for the whole batch. Each item starts out with these values in Item
|
||||
Details Entry and can still be adjusted individually.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="preset-fields mb-4">
|
||||
<div v-for="field in presetFieldDefs" :key="field.key" class="card mb-3">
|
||||
<div class="card-body">
|
||||
<label class="form-label fw-bold">{{ field.label }}</label>
|
||||
<field-input :type="field.type" v-model="presetValues[field.key]"
|
||||
:owner-groups="ownerGroups" :availability-policies="availability_policies"
|
||||
:visibility-policies="visibility_policies"
|
||||
:storage-locations="storage_locations"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="proceedFromPresets">
|
||||
Next: Enter Item Details
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Item Details Entry -->
|
||||
<div v-else-if="step === '3'" class="foto-first-step-3">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">Item Details Entry</h4>
|
||||
<p class="text-muted">Enter the remaining details for each photographed item.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="actionError" class="alert alert-danger" role="alert">{{ actionError }}</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div class="progress-indicator mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="mb-0">Item Progress</h6>
|
||||
<span class="badge bg-info">{{ overallPosition }} of {{ totalItems }}</span>
|
||||
</div>
|
||||
<div class="progress mb-2" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar bg-info"
|
||||
:style="{ width: itemProgressPercentage + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Importing indicator -->
|
||||
<div v-if="importing" class="current-item mb-4 text-center py-5">
|
||||
<div class="spinner-border text-success mb-3" role="status"></div>
|
||||
<h5 class="mb-0">Finalizing import...</h5>
|
||||
</div>
|
||||
|
||||
<!-- Current Item Display -->
|
||||
<div v-else-if="currentItem" class="current-item mb-4">
|
||||
<div class="row">
|
||||
<!-- Image Preview -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<img v-if="currentItem.dataUrl" :src="currentItem.dataUrl" class="card-img-top item-image"
|
||||
alt="Current item">
|
||||
<authenticated-image v-else :src="thumbnailPathForHash(currentItem.hash)" :owner="user"
|
||||
img-class="card-img-top item-image" alt="Current item"/>
|
||||
<div class="card-body p-2">
|
||||
<small class="text-muted">{{ currentItem.name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Details Form -->
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0">Item Details</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="saveCurrentItem">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
:class="itemErrors.name ? ['is-invalid'] : []"
|
||||
v-model="currentItemDetails.name"
|
||||
required
|
||||
placeholder="Enter item name"
|
||||
>
|
||||
<div class="invalid-feedback">{{ itemErrors.name }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="field in presetFieldDefs" :key="field.key" class="mb-3">
|
||||
<label class="form-label">{{ field.label }}</label>
|
||||
<field-input :type="field.type" v-model="currentItemDetails[field.key]"
|
||||
:owner-groups="ownerGroups"
|
||||
:availability-policies="availability_policies"
|
||||
:visibility-policies="visibility_policies"
|
||||
:storage-locations="storage_locations"/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Navigation -->
|
||||
<div v-if="!importing" class="item-navigation mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
@click="previousItem"
|
||||
:disabled="currentItemIndex === 0"
|
||||
>
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous Item
|
||||
</button>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary" @click="saveCurrentItem" :disabled="saving">
|
||||
<b-icon-check class="me-1"></b-icon-check>
|
||||
{{ saving ? 'Saving...' : 'Save Item' }}
|
||||
</button>
|
||||
<button class="btn btn-outline-primary" @click="skipCurrentItem" :disabled="saving">
|
||||
<b-icon-skip-forward class="me-1"></b-icon-skip-forward>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
@click="nextItem"
|
||||
:disabled="currentItemIndex >= availableItems.length - 1"
|
||||
>
|
||||
Next Item
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Items Summary -->
|
||||
<div v-if="completedItems.length > 0" class="completed-items mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Completed Items ({{ completedItems.length }})</h6>
|
||||
<button class="btn btn-sm btn-outline-info" @click="showCompleted = !showCompleted">
|
||||
<b-icon-eye v-if="!showCompleted"></b-icon-eye>
|
||||
<b-icon-eye-slash v-else></b-icon-eye-slash>
|
||||
{{ showCompleted ? 'Hide' : 'Show' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showCompleted" class="card-body">
|
||||
<div class="row">
|
||||
<div v-for="(item, index) in completedItems" :key="index"
|
||||
class="col-sm-6 col-md-4 col-lg-3 mb-2">
|
||||
<!-- Live datasource: this fetches and shows the item's current name/photo,
|
||||
not a copy taken at creation time, which could be stale by now. -->
|
||||
<foto-first-completed-item-card :id="item.id" :owner="item.owner"
|
||||
:owner-group="item.owner_group"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div v-if="!importing" class="step-navigation d-flex justify-content-between">
|
||||
<button class="btn btn-outline-secondary" @click="$emit('prev')">
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback for unknown steps -->
|
||||
<div v-else class="text-center py-5">
|
||||
<b-icon-gear class="text-muted mb-3" style="font-size: 3rem;"></b-icon-gear>
|
||||
<h5 class="text-muted">Unknown step "{{ step }}"</h5>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
|
||||
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
|
||||
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||
import WebcamFileSource from "@/components/inputs/WebcamFileSource.vue";
|
||||
import FieldInput from "@/components/workflow/workflows/FotoFirstFieldInput.vue";
|
||||
import FotoFirstCompletedItemCard from "@/components/workflow/workflows/FotoFirstCompletedItemCard.vue";
|
||||
|
||||
// Mirrors the fields InventoryNew.vue collects when creating a single item, minus `name` and
|
||||
// `files` (name stays per-item/required, files are the photo already staged in step 1).
|
||||
const PRESET_FIELD_DEFS = [
|
||||
{key: 'description', label: 'Description', type: 'textarea'},
|
||||
{key: 'tags', label: 'Tags', type: 'tags'},
|
||||
{key: 'properties', label: 'Properties', type: 'properties'},
|
||||
{key: 'owner_group', label: 'Owner', type: 'owner_group'},
|
||||
{key: 'owned_quantity', label: 'Quantity', type: 'number'},
|
||||
{key: 'availability_policy', label: 'Availability Policy', type: 'availability_policy'},
|
||||
{key: 'visibility_policy', label: 'Visibility Policy', type: 'visibility_policy'},
|
||||
{key: 'storage_location', label: 'Storage Location', type: 'storage_location'}
|
||||
];
|
||||
|
||||
function defaultValueFor(key) {
|
||||
switch (key) {
|
||||
case 'tags':
|
||||
case 'properties':
|
||||
return [];
|
||||
case 'owner_group':
|
||||
case 'storage_location':
|
||||
return null;
|
||||
case 'owned_quantity':
|
||||
return 1;
|
||||
case 'availability_policy':
|
||||
case 'visibility_policy':
|
||||
return 'private';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function defaultPresetValues() {
|
||||
const values = {};
|
||||
PRESET_FIELD_DEFS.forEach(field => {
|
||||
values[field.key] = defaultValueFor(field.key);
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstBulkImportWorkflow',
|
||||
meta: {
|
||||
slug: 'foto-first-bulk-import',
|
||||
title: 'Foto First Bulk Import',
|
||||
category: 'Data Management',
|
||||
description: 'Capture unlimited photos via mobile camera or upload images, then sequentially enter details for each item.',
|
||||
icons: ['b-icon-camera', 'b-icon-pencil-square'],
|
||||
estimatedDuration: '10-60 minutes',
|
||||
stepDefinitions: [
|
||||
{step: '1', name: 'Photo Capture', description: 'Capture or upload item photos'},
|
||||
{step: '2', name: 'Data Presets', description: 'Set properties shared by the whole batch'},
|
||||
{step: '3', name: 'Item Details Entry', description: 'Enter remaining details for each photographed item'}
|
||||
],
|
||||
getInitialPayload() {
|
||||
return {
|
||||
data_presets: {
|
||||
values: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
components: {
|
||||
WebcamFileSource,
|
||||
AuthenticatedImage,
|
||||
DragDropFileSource,
|
||||
CameraFileSource,
|
||||
FsFileSource,
|
||||
FieldInput,
|
||||
FotoFirstCompletedItemCard,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Step 1: photo capture
|
||||
photos: [],
|
||||
|
||||
// Step 2: data presets
|
||||
presetFieldDefs: PRESET_FIELD_DEFS,
|
||||
presetValues: defaultPresetValues(),
|
||||
|
||||
// Step 3: item details entry
|
||||
currentItemIndex: 0,
|
||||
// Can't call this.getDefaultItemDetails() here - it reads this.presetValues, which
|
||||
// Vue hasn't assigned yet while still evaluating data()'s own return object.
|
||||
currentItemDetails: {name: '', ...defaultPresetValues()},
|
||||
itemErrors: {name: ''},
|
||||
actionError: '',
|
||||
completedItems: [],
|
||||
showCompleted: false,
|
||||
saving: false,
|
||||
importing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['user', 'availability_policies', 'visibility_policies', 'storage_locations', 'groups', 'groupMemberships']),
|
||||
ownerGroups() {
|
||||
const hostedHandles = new Set(this.groups.map(group => group.handle));
|
||||
const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle));
|
||||
return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle));
|
||||
},
|
||||
availableItems() {
|
||||
return this.photos;
|
||||
},
|
||||
totalItems() {
|
||||
return this.availableItems.length + this.completedItems.length;
|
||||
},
|
||||
currentItem() {
|
||||
return this.availableItems[this.currentItemIndex] || null;
|
||||
},
|
||||
overallPosition() {
|
||||
return this.completedItems.length + this.currentItemIndex + 1;
|
||||
},
|
||||
itemProgressPercentage() {
|
||||
return this.totalItems > 0 ? (this.completedItems.length / this.totalItems) * 100 : 0;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.loadFromPayload();
|
||||
await Promise.all([
|
||||
this.fetchInfo(),
|
||||
this.fetchStorageLocations(),
|
||||
this.fetchGroups(),
|
||||
this.fetchGroupMemberships(),
|
||||
this.fetchTags(),
|
||||
this.fetchProperties()
|
||||
]);
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['stageFile', 'unstageFile', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
|
||||
'fetchGroupMemberships', 'fetchTags', 'fetchProperties', 'createInventoryItem', 'commitStagedFile']),
|
||||
loadFromPayload() {
|
||||
// `photos` is seeded from workflowInstance.staged_files, not payload. See docs/implementation.md#staged-photos-are-the-durable-state.
|
||||
this.photos = (this.workflowInstance.staged_files || []).map(hash => ({
|
||||
hash,
|
||||
name: null,
|
||||
size: null,
|
||||
mime_type: null,
|
||||
dataUrl: null,
|
||||
uploaded: true,
|
||||
timestamp: null
|
||||
}));
|
||||
if (this.payload.data_presets) {
|
||||
this.presetValues = {...defaultPresetValues(), ...this.payload.data_presets.values};
|
||||
}
|
||||
if (this.payload.completed_items) this.completedItems = [...this.payload.completed_items];
|
||||
if (this.payload.current_item_details) this.currentItemDetails = {...this.payload.current_item_details};
|
||||
if (this.payload.current_item_index !== undefined) this.currentItemIndex = this.payload.current_item_index;
|
||||
},
|
||||
|
||||
// --- Step 1: Photo capture ---
|
||||
thumbnailPathForHash(hash, size = 256) {
|
||||
// Derived storage path for a disk-cached resized thumbnail. See docs/implementation.md#thumbnail-lookup-by-hash.
|
||||
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
|
||||
},
|
||||
|
||||
async addStagedFiles(files) {
|
||||
const new_files = files.filter(file => !this.photos.find(photo => photo.hash === file.hash));
|
||||
if (new_files.length === 0) return;
|
||||
|
||||
const staged = new_files.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime_type: file.mime_type,
|
||||
hash: file.hash, // SHA-256, same algorithm the backend hashes File content with
|
||||
data: file.data,
|
||||
dataUrl: `data:${file.mime_type};base64,${file.data}`,
|
||||
uploaded: false,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
this.photos.push(...staged);
|
||||
|
||||
// Persists each photo server-side immediately so it survives a reload/device switch. See docs/implementation.md#staged-photos-are-the-durable-state.
|
||||
await Promise.all(staged.map(async ({hash, data, mime_type}) => {
|
||||
try {
|
||||
await this.stageFile({
|
||||
lifetime_id: this.workflowInstance.id,
|
||||
file: {data, mime_type}
|
||||
});
|
||||
// Re-lookup by hash rather than mutate the closed-over `photo`, which predates this.photos.push() and isn't Vue's reactive proxy. See docs/implementation.md#thumbnail-lookup-by-hash.
|
||||
const photo = this.photos.find(p => p.hash === hash);
|
||||
if (photo) photo.uploaded = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to stage photo:', error);
|
||||
this.photos = this.photos.filter(p => p.hash !== hash);
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
async removePhoto(index) {
|
||||
const [photo] = this.photos.splice(index, 1);
|
||||
if (photo) {
|
||||
try {
|
||||
await this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash});
|
||||
} catch (error) {
|
||||
console.error('Failed to unstage photo:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async clearAllPhotos() {
|
||||
if (confirm('Are you sure you want to remove all photos?')) {
|
||||
const removed = this.photos;
|
||||
this.photos = [];
|
||||
await Promise.all(removed.map(photo =>
|
||||
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
|
||||
.catch(error => console.error('Failed to unstage photo:', error))
|
||||
));
|
||||
}
|
||||
},
|
||||
|
||||
proceedFromStep1() {
|
||||
this.$emit('next');
|
||||
},
|
||||
|
||||
// --- Step 2: Data presets ---
|
||||
proceedFromPresets() {
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
this.$emit('update', {
|
||||
data_presets: {values: this.presetValues}
|
||||
});
|
||||
this.$emit('next');
|
||||
},
|
||||
|
||||
// --- Step 3: Item details entry ---
|
||||
getDefaultItemDetails() {
|
||||
const base = {name: ''};
|
||||
PRESET_FIELD_DEFS.forEach(field => {
|
||||
const value = this.presetValues[field.key];
|
||||
// tag-field/property-field mutate their array in place, so each item needs its own
|
||||
// copy - otherwise editing one item's tags would silently edit every other item's too.
|
||||
base[field.key] = Array.isArray(value) ? [...value] : value;
|
||||
});
|
||||
return base;
|
||||
},
|
||||
|
||||
async saveCurrentItem() {
|
||||
this.itemErrors = {name: ''};
|
||||
this.actionError = '';
|
||||
if (!this.currentItemDetails.name.trim()) {
|
||||
this.itemErrors = {name: 'Please enter an item name before saving.'};
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
try {
|
||||
const photo = this.currentItem;
|
||||
const created = await this.createItem(photo, this.currentItemDetails);
|
||||
// `id`/`owner`/`owner_group` are the only fields that identify the created
|
||||
// InventoryItem - never cache its name/tags/photo/etc. here, since the item can be
|
||||
// edited elsewhere (or by the user themselves) before this workflow finishes, and
|
||||
// a cached copy would silently go stale.
|
||||
this.completedItems.push({id: created.id, owner: created.owner, owner_group: created.owner_group});
|
||||
// createItem() already committed the photo's file to the item, which detaches it
|
||||
// from this workflow's staged files server-side - drop it here too so this array
|
||||
// stays in sync, and so it can never be revisited as if still pending.
|
||||
this.photos = this.photos.filter(p => p.hash !== photo.hash);
|
||||
if (this.currentItemIndex >= this.photos.length) {
|
||||
this.currentItemIndex = Math.max(0, this.photos.length - 1);
|
||||
}
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
|
||||
if (this.photos.length > 0) {
|
||||
// Only PATCH here when items remain - if that was the last one, finishImport()
|
||||
// below sends its own (already up to date) consolidated PATCH, and firing both
|
||||
// back-to-back races two writes against dev's SQLite ("database is locked").
|
||||
this.updateItemsPayload();
|
||||
} else {
|
||||
this.finishImport();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create item:', this.currentItemDetails.name, error);
|
||||
this.actionError = `Failed to save "${this.currentItemDetails.name}". Please try again.`;
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
skipCurrentItem() {
|
||||
this.itemErrors = {name: ''};
|
||||
this.actionError = '';
|
||||
if (this.currentItemIndex < this.photos.length - 1) {
|
||||
this.currentItemIndex++;
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
} else {
|
||||
this.finishImport();
|
||||
}
|
||||
},
|
||||
|
||||
nextItem() {
|
||||
this.itemErrors = {name: ''};
|
||||
this.actionError = '';
|
||||
if (this.currentItemIndex < this.photos.length - 1) {
|
||||
this.currentItemIndex++;
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
}
|
||||
},
|
||||
|
||||
previousItem() {
|
||||
this.itemErrors = {name: ''};
|
||||
this.actionError = '';
|
||||
if (this.currentItemIndex > 0) {
|
||||
this.currentItemIndex--;
|
||||
this.currentItemDetails = this.getDefaultItemDetails();
|
||||
}
|
||||
},
|
||||
|
||||
updateItemsPayload() {
|
||||
this.$emit('update', {
|
||||
completed_items: this.completedItems,
|
||||
current_item_details: this.currentItemDetails,
|
||||
current_item_index: this.currentItemIndex
|
||||
});
|
||||
},
|
||||
|
||||
// --- Creates the real InventoryItem the moment its details are saved ---
|
||||
async createItem(photo, details) {
|
||||
const created = await this.createInventoryItem({
|
||||
name: details.name,
|
||||
description: details.description,
|
||||
tags_input: details.tags,
|
||||
properties: details.properties,
|
||||
owned_quantity: details.owned_quantity,
|
||||
availability_policy: details.availability_policy,
|
||||
visibility_policy: details.visibility_policy,
|
||||
storage_location: details.storage_location,
|
||||
owner_group: details.owner_group
|
||||
});
|
||||
if (photo?.hash) {
|
||||
await this.commitStagedFile({item_id: created.id, file_hash: photo.hash});
|
||||
// Detach the file from *this* workflow specifically now that it belongs to the
|
||||
// item - scoped by workflow id, unlike a blanket clear, since files are content-
|
||||
// addressed and the same hash could be staged on other workflows too.
|
||||
await this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash});
|
||||
}
|
||||
return created;
|
||||
},
|
||||
|
||||
// --- Finalization once the last item has been saved/skipped ---
|
||||
async finishImport() {
|
||||
if (this.completedItems.length === 0) {
|
||||
this.actionError = 'No items were saved. Please go back and save at least one item.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.importing = true;
|
||||
try {
|
||||
this.$emit('update', {
|
||||
completed_items: this.completedItems,
|
||||
current_item_details: this.currentItemDetails,
|
||||
current_item_index: this.currentItemIndex,
|
||||
import_completed: true,
|
||||
completion_timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// The parent's 'update' and 'complete' handlers each PATCH the same workflow row;
|
||||
// firing them back-to-back can hit two overlapping writes against dev's SQLite,
|
||||
// which only allows one writer at a time ("database is locked"). Give the first
|
||||
// PATCH a moment to land before triggering the second.
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
this.$emit('complete');
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.photo-thumbnail {
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Stacks the dataUrl preview and server-fetched image during their crossfade so neither disappears before the other lays out. */
|
||||
.photo-thumb-wrap {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-thumb-wrap .photo-thumbnail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.photo-wipe-enter-active,
|
||||
.photo-wipe-leave-active {
|
||||
transition: clip-path 0.5s ease, opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.photo-wipe-enter-from {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.photo-wipe-enter-to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-wipe-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-wipe-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.staging-area .card {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.staging-area .card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.item-image {
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.completed-item-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.item-navigation {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<div class="d-flex align-items-center">
|
||||
<div v-if="loading" class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<template v-else-if="item">
|
||||
<authenticated-image v-if="thumbnailFile" :src="thumbnailPathForHash(thumbnailFile.hash)"
|
||||
:owner="thumbnailFile.owner" img-class="completed-item-thumb me-2"/>
|
||||
<b-icon-image v-else class="me-2"></b-icon-image>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold small">{{ item.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-muted small">
|
||||
<b-icon-exclamation-triangle class="me-1"></b-icon-exclamation-triangle>
|
||||
Item #{{ id }} (unavailable)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstCompletedItemCard',
|
||||
components: {AuthenticatedImage, ...BIcons},
|
||||
props: {
|
||||
id: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
owner: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
ownerGroup: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
item: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
thumbnailFile() {
|
||||
return this.item?.files?.find(file => file.mime_type?.startsWith('image/')) || null;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
// Always live: this card exists specifically so the summary reflects the item's current
|
||||
// name/photo, not whatever it looked like at the moment this workflow created it.
|
||||
try {
|
||||
this.item = await this.fetchItemByHandle({handle: this.ownerGroup || this.owner, id: this.id});
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchItemByHandle']),
|
||||
// Requests a small server-resized/cached thumbnail instead of the original upload - this
|
||||
// renders in a fixed 40px box (see .completed-item-thumb), and the source photos here can
|
||||
// be full camera resolution, so fetching the original would be a lot of wasted bandwidth
|
||||
// and decode work for a summary list. Mirrors FotoFirstBulkImportWorkflow.vue's own
|
||||
// thumbnailPathForHash(); see docs/implementation.md#thumbnail-lookup-by-hash.
|
||||
thumbnailPathForHash(hash, size = 64) {
|
||||
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<template>
|
||||
<textarea v-if="type === 'textarea'" class="form-control" rows="2" v-model="localValue"></textarea>
|
||||
|
||||
<input v-else-if="type === 'number'" type="number" class="form-control" min="0" v-model.number="localValue">
|
||||
|
||||
<!-- Tags/properties mutate the passed-in array in place (same convention as
|
||||
InventoryNew.vue's own `:value="item.tags"`), so no v-model wiring is needed here. -->
|
||||
<tag-field v-else-if="type === 'tags'" :value="modelValue"></tag-field>
|
||||
|
||||
<property-field v-else-if="type === 'properties'" :value="modelValue"></property-field>
|
||||
|
||||
<select v-else-if="type === 'owner_group'" class="form-select" v-model="localValue">
|
||||
<option :value="null">Myself</option>
|
||||
<option v-for="group in ownerGroups" :key="group.handle" :value="group.handle">{{ group.handle }}</option>
|
||||
</select>
|
||||
|
||||
<select v-else-if="type === 'availability_policy'" class="form-select" v-model="localValue">
|
||||
<option v-for="policy in availabilityPolicies" :key="policy.slug" :value="policy.slug">
|
||||
{{ policy.text }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-else-if="type === 'visibility_policy'" class="form-select" v-model="localValue">
|
||||
<option v-for="policy in visibilityPolicies" :key="policy.slug" :value="policy.slug">
|
||||
{{ policy.text }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-else-if="type === 'storage_location'" class="form-select" v-model="localValue">
|
||||
<option :value="null">No storage location</option>
|
||||
<option v-for="location in storageLocations" :key="location.id" :value="location.id">
|
||||
{{ location.path }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input v-else type="text" class="form-control" v-model="localValue">
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TagField from "@/components/TagField.vue";
|
||||
import PropertyField from "@/components/PropertyField.vue";
|
||||
|
||||
export default {
|
||||
name: 'FotoFirstFieldInput',
|
||||
components: {TagField, PropertyField},
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
modelValue: {
|
||||
default: null
|
||||
},
|
||||
ownerGroups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availabilityPolicies: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
visibilityPolicies: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
storageLocations: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
computed: {
|
||||
localValue: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<div class="inventory-audit-workflow">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
|
||||
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step Progress -->
|
||||
<div class="progress mb-4" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Step Content -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center py-5">
|
||||
<b-icon-list-ul class="text-muted mb-3" style="font-size: 3rem;"></b-icon-list-ul>
|
||||
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
|
||||
<p class="text-muted">Implementation coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button
|
||||
v-if="parseInt(step) > 1"
|
||||
class="btn btn-outline-secondary"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
<button
|
||||
v-if="parseInt(step) < meta.stepDefinitions.length"
|
||||
class="btn btn-primary"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
Next
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-success"
|
||||
@click="$emit('complete')"
|
||||
>
|
||||
Complete
|
||||
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from 'bootstrap-icons-vue';
|
||||
|
||||
export default {
|
||||
name: 'InventoryAuditWorkflow',
|
||||
meta: {
|
||||
slug: 'inventory-audit',
|
||||
title: 'Inventory Audit',
|
||||
category: 'Inventory Management',
|
||||
description: 'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.',
|
||||
icons: ['b-icon-list-ul'],
|
||||
estimatedDuration: '2-4 hours',
|
||||
stepDefinitions: [
|
||||
{ step: '1', name: 'Initialize Audit', description: 'Set up audit parameters and scope' },
|
||||
{ step: '2', name: 'Generate Item List', description: 'Create list of items to audit' },
|
||||
{ step: '3', name: 'Location Verification', description: 'Verify item locations' },
|
||||
{ step: '4', name: 'Quantity Count', description: 'Count physical quantities' },
|
||||
{ step: '5', name: 'Condition Assessment', description: 'Assess item conditions' },
|
||||
{ step: '6', name: 'Discrepancy Detection', description: 'Identify discrepancies' },
|
||||
{ step: '7', name: 'Report Generation', description: 'Generate audit report' },
|
||||
{ step: '8', name: 'Finalize Audit', description: 'Complete and archive audit' }
|
||||
],
|
||||
getInitialPayload() {
|
||||
return {
|
||||
audit_parameters: {
|
||||
scope: 'full',
|
||||
include_all_categories: true
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCurrentStepName() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.name : `Step ${this.step}`;
|
||||
},
|
||||
getCurrentStepDescription() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.description : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inventory-audit-workflow {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.step-navigation {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
<template>
|
||||
<div class="maintenance-schedule-workflow">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
|
||||
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step Progress -->
|
||||
<div class="progress mb-4" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Step Content -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center py-5">
|
||||
<b-icon-tools class="text-muted mb-3" style="font-size: 3rem;"></b-icon-tools>
|
||||
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
|
||||
<p class="text-muted">Implementation coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button
|
||||
v-if="parseInt(step) > 1"
|
||||
class="btn btn-outline-secondary"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
<button
|
||||
v-if="parseInt(step) < meta.stepDefinitions.length"
|
||||
class="btn btn-primary"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
Next
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-success"
|
||||
@click="$emit('complete')"
|
||||
>
|
||||
Complete
|
||||
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from 'bootstrap-icons-vue';
|
||||
|
||||
export default {
|
||||
name: 'MaintenanceScheduleWorkflow',
|
||||
meta: {
|
||||
slug: 'maintenance-schedule',
|
||||
title: 'Maintenance Schedule',
|
||||
category: 'Tool Maintenance',
|
||||
description: 'Create and execute maintenance schedules for tools and equipment.',
|
||||
icons: ['b-icon-tools', 'b-icon-calendar'],
|
||||
estimatedDuration: '30 minutes',
|
||||
stepDefinitions: [
|
||||
{ step: '1', name: 'Identify Equipment', description: 'Select tools and equipment for maintenance' },
|
||||
{ step: '2', name: 'Create Schedule', description: 'Define maintenance intervals and tasks' },
|
||||
{ step: '3', name: 'Assign Responsibilities', description: 'Assign maintenance tasks to users' },
|
||||
{ step: '4', name: 'Activate Schedule', description: 'Enable automatic maintenance reminders' }
|
||||
],
|
||||
getInitialPayload() {
|
||||
return {
|
||||
schedule_config: {
|
||||
frequency: 'monthly'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCurrentStepName() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.name : `Step ${this.step}`;
|
||||
},
|
||||
getCurrentStepDescription() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.description : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.maintenance-schedule-workflow {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.step-navigation {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<div class="storage-optimization-workflow">
|
||||
<div class="step-header mb-4">
|
||||
<h4 class="mb-2">{{ getCurrentStepName() }}</h4>
|
||||
<p class="text-muted">{{ getCurrentStepDescription() }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step Progress -->
|
||||
<div class="progress mb-4" style="height: 8px;">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: (parseInt(step) / meta.stepDefinitions.length) * 100 + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Generic Step Content -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body text-center py-5">
|
||||
<b-icon-boxes class="text-muted mb-3" style="font-size: 3rem;"></b-icon-boxes>
|
||||
<h5 class="text-muted">{{ getCurrentStepName() }}</h5>
|
||||
<p class="text-muted">Implementation coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="step-navigation d-flex justify-content-between">
|
||||
<button
|
||||
v-if="parseInt(step) > 1"
|
||||
class="btn btn-outline-secondary"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
|
||||
Previous
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
<button
|
||||
v-if="parseInt(step) < meta.stepDefinitions.length"
|
||||
class="btn btn-primary"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
Next
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-success"
|
||||
@click="$emit('complete')"
|
||||
>
|
||||
Complete
|
||||
<b-icon-check-circle class="ms-1"></b-icon-check-circle>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as BIcons from 'bootstrap-icons-vue';
|
||||
|
||||
export default {
|
||||
name: 'StorageOptimizationWorkflow',
|
||||
meta: {
|
||||
slug: 'storage-optimization',
|
||||
title: 'Storage Optimization',
|
||||
category: 'Storage Management',
|
||||
description: 'Analyze and reorganize storage locations for maximum efficiency and accessibility.',
|
||||
icons: ['b-icon-boxes', 'b-icon-diagram-3', 'b-icon-archive'],
|
||||
estimatedDuration: '1-2 hours',
|
||||
stepDefinitions: [
|
||||
{ step: '1', name: 'Analyze Current Layout', description: 'Assess current storage efficiency' },
|
||||
{ step: '2', name: 'Identify Optimization Opportunities', description: 'Find areas for improvement' },
|
||||
{ step: '3', name: 'Plan Reorganization', description: 'Create optimization plan' },
|
||||
{ step: '4', name: 'Execute Changes', description: 'Implement storage changes' },
|
||||
{ step: '5', name: 'Validate Results', description: 'Verify optimization results' }
|
||||
],
|
||||
getInitialPayload() {
|
||||
return {
|
||||
optimization_params: {
|
||||
strategy: 'efficiency'
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
workflowInstance: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
step: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
payload: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCurrentStepName() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.name : `Step ${this.step}`;
|
||||
},
|
||||
getCurrentStepDescription() {
|
||||
const stepDef = this.meta.stepDefinitions.find(s => s.step === this.step);
|
||||
return stepDef ? stepDef.description : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.storage-optimization-workflow {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.step-navigation {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -13,9 +13,13 @@ function get_prefered_server() {
|
|||
request.open('GET', '/local/dns', false);
|
||||
request.send(null);
|
||||
if (request.status === 200) {
|
||||
const servers = JSON.parse(request.responseText);
|
||||
if (servers && servers.length > 0) {
|
||||
return servers;
|
||||
try {
|
||||
const servers = JSON.parse(request.responseText);
|
||||
if (servers && servers.length > 0) {
|
||||
return servers;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return ['1.1.1.1', '8.8.8.8'];
|
||||
|
|
|
|||
|
|
@ -33,16 +33,13 @@ class ServerSet {
|
|||
}
|
||||
const url = "https://" + server + target // TODO https
|
||||
return await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
method: 'GET', headers: {
|
||||
...auth.buildAuthHeader(url)
|
||||
},
|
||||
credentials: 'omit'
|
||||
}, credentials: 'omit'
|
||||
}).catch(err => {
|
||||
console.error('get from server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}
|
||||
).then(response => response.json())
|
||||
console.error('get from server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}).then(response => response.json())
|
||||
} catch (e) {
|
||||
console.error('get from server failed', server, e)
|
||||
}
|
||||
|
|
@ -61,18 +58,13 @@ class ServerSet {
|
|||
}
|
||||
const url = "https://" + server + target // TODO https
|
||||
return await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...auth.buildAuthHeader(url, data)
|
||||
},
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(data)
|
||||
method: 'POST', headers: {
|
||||
'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
|
||||
}, credentials: 'omit', body: JSON.stringify(data)
|
||||
}).catch(err => {
|
||||
console.error('post to server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}
|
||||
).then(response => response.json())
|
||||
console.error('post to server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}).then(response => response.json())
|
||||
} catch (e) {
|
||||
console.error('post to server failed', server, e)
|
||||
}
|
||||
|
|
@ -91,18 +83,13 @@ class ServerSet {
|
|||
}
|
||||
const url = "https://" + server + target // TODO https
|
||||
return await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...auth.buildAuthHeader(url, data)
|
||||
},
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(data)
|
||||
method: 'PATCH', headers: {
|
||||
'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
|
||||
}, credentials: 'omit', body: JSON.stringify(data)
|
||||
}).catch(err => {
|
||||
console.error('patch to server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}
|
||||
).then(response => response.json())
|
||||
console.error('patch to server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}).then(response => response.json())
|
||||
} catch (e) {
|
||||
console.error('patch to server failed', server, e)
|
||||
}
|
||||
|
|
@ -121,18 +108,13 @@ class ServerSet {
|
|||
}
|
||||
const url = "https://" + server + target // TODO https
|
||||
return await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...auth.buildAuthHeader(url, data)
|
||||
},
|
||||
credentials: 'omit',
|
||||
body: JSON.stringify(data)
|
||||
method: 'PUT', headers: {
|
||||
'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
|
||||
}, credentials: 'omit', body: JSON.stringify(data)
|
||||
}).catch(err => {
|
||||
console.error('put to server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}
|
||||
).then(response => response.json())
|
||||
console.error('put to server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}).then(response => response.json())
|
||||
} catch (e) {
|
||||
console.error('put to server failed', server, e)
|
||||
}
|
||||
|
|
@ -151,16 +133,13 @@ class ServerSet {
|
|||
}
|
||||
const url = "https://" + server + target // TODO https
|
||||
return await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
method: 'DELETE', headers: {
|
||||
...auth.buildAuthHeader(url)
|
||||
},
|
||||
credentials: 'omit'
|
||||
}, credentials: 'omit'
|
||||
}).catch(err => {
|
||||
console.error('delete from server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}
|
||||
)
|
||||
console.error('delete from server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('delete from server failed', server, e)
|
||||
}
|
||||
|
|
@ -179,102 +158,163 @@ class ServerSet {
|
|||
}
|
||||
const url = "https://" + server + target // TODO https
|
||||
return await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
method: 'GET', headers: {
|
||||
...auth.buildAuthHeader(url)
|
||||
},
|
||||
credentials: 'omit'
|
||||
}, credentials: 'omit'
|
||||
}).catch(err => {
|
||||
console.error('get from server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
}
|
||||
)
|
||||
console.error('get from server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('get from server failed', server, e)
|
||||
}
|
||||
}
|
||||
throw new Error('all servers failed')
|
||||
}
|
||||
|
||||
async postRaw(auth, target, data) {
|
||||
if (!auth || typeof auth.buildAuthHeader !== 'function') {
|
||||
throw new Error('no auth')
|
||||
}
|
||||
for (const server of this.servers) {
|
||||
try {
|
||||
if (this.unreachable_neighbors.queryUnreachable(server)) {
|
||||
continue
|
||||
}
|
||||
const url = "https://" + server + target // TODO https
|
||||
return await fetch(url, {
|
||||
method: 'POST', headers: {
|
||||
'Content-Type': 'application/json', ...auth.buildAuthHeader(url, data)
|
||||
}, credentials: 'omit', body: data
|
||||
}).catch(err => {
|
||||
console.error('post to server failed', server, err)
|
||||
this.unreachable_neighbors.unreachable(server)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('post to server failed', server, e)
|
||||
}
|
||||
}
|
||||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
|
||||
class ServerSetUnion {
|
||||
constructor(serverSets) {
|
||||
if (!serverSets || !Array.isArray(serverSets)) {
|
||||
throw new Error('no serverSets')
|
||||
}
|
||||
this.serverSets = serverSets;
|
||||
}
|
||||
//class ServerSetUnion {
|
||||
// constructor(serverSets) {
|
||||
// if (!serverSets || !Array.isArray(serverSets)) {
|
||||
// throw new Error('no serverSets')
|
||||
// }
|
||||
// this.serverSets = serverSets;
|
||||
// }
|
||||
//
|
||||
// add(serverset) {
|
||||
// if (!serverset || !(serverset instanceof ServerSet)) {
|
||||
// throw new Error('no serverset')
|
||||
// }
|
||||
// if (this.serverSets.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
|
||||
// console.warn('serverset already in union', serverset)
|
||||
// return
|
||||
// }
|
||||
// this.serverSets.push(serverset)
|
||||
// }
|
||||
//
|
||||
// async get(auth, target) {
|
||||
// try {
|
||||
// return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
// return acc.then(async (acc) => {
|
||||
// return acc.concat(await serverset.get(auth, target))
|
||||
// })
|
||||
// }, Promise.resolve([]))
|
||||
// } catch (e) {
|
||||
// throw new Error('all servers failed')
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// async post(auth, target, data) {
|
||||
// try {
|
||||
// return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
// return acc.then(async (acc) => {
|
||||
// return acc.concat(await serverset.post(auth, target, data))
|
||||
// })
|
||||
// }, Promise.resolve([]))
|
||||
// } catch (e) {
|
||||
// throw new Error('all servers failed')
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// async patch(auth, target, data) {
|
||||
// try {
|
||||
// return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
// return acc.then(async (acc) => {
|
||||
// return acc.concat(await serverset.patch(auth, target, data))
|
||||
// })
|
||||
// }, Promise.resolve([]))
|
||||
// } catch (e) {
|
||||
// throw new Error('all servers failed')
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// async put(auth, target, data) {
|
||||
// try {
|
||||
// return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
// return acc.then(async (acc) => {
|
||||
// return acc.concat(await serverset.put(auth, target, data))
|
||||
// })
|
||||
// }, Promise.resolve([]))
|
||||
// } catch (e) {
|
||||
// throw new Error('all servers failed')
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// async delete(auth, target) {
|
||||
// try {
|
||||
// return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
// return acc.then(async (acc) => {
|
||||
// return acc.concat(await serverset.delete(auth, target))
|
||||
// })
|
||||
// }, Promise.resolve([]))
|
||||
// } catch (e) {
|
||||
// throw new Error('all servers failed')
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
add(serverset) {
|
||||
if (!serverset || !(serverset instanceof ServerSet)) {
|
||||
throw new Error('no serverset')
|
||||
}
|
||||
if (this.serverSets.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
|
||||
console.warn('serverset already in union', serverset)
|
||||
return
|
||||
}
|
||||
this.serverSets.push(serverset)
|
||||
}
|
||||
|
||||
async get(auth, target) {
|
||||
try {
|
||||
return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
return acc.then(async (acc) => {
|
||||
return acc.concat(await serverset.get(auth, target))
|
||||
})
|
||||
}, Promise.resolve([]))
|
||||
} catch (e) {
|
||||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
|
||||
async post(auth, target, data) {
|
||||
try {
|
||||
return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
return acc.then(async (acc) => {
|
||||
return acc.concat(await serverset.post(auth, target, data))
|
||||
})
|
||||
}, Promise.resolve([]))
|
||||
} catch (e) {
|
||||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
|
||||
async patch(auth, target, data) {
|
||||
try {
|
||||
return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
return acc.then(async (acc) => {
|
||||
return acc.concat(await serverset.patch(auth, target, data))
|
||||
})
|
||||
}, Promise.resolve([]))
|
||||
} catch (e) {
|
||||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
|
||||
async put(auth, target, data) {
|
||||
try {
|
||||
return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
return acc.then(async (acc) => {
|
||||
return acc.concat(await serverset.put(auth, target, data))
|
||||
})
|
||||
}, Promise.resolve([]))
|
||||
} catch (e) {
|
||||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
|
||||
async delete(auth, target) {
|
||||
try {
|
||||
return await this.serverSets.reduce(async (acc, serverset) => {
|
||||
return acc.then(async (acc) => {
|
||||
return acc.concat(await serverset.delete(auth, target))
|
||||
})
|
||||
}, Promise.resolve([]))
|
||||
} catch (e) {
|
||||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
function ServerSetUnion(serverSets) {
|
||||
return new Proxy(serverSets, {
|
||||
get: function (target, prop, receiver) {
|
||||
// Must precede the generic forwarding check below: ServerSet.prototype also has its
|
||||
// own add(server), so funcs.includes('add') would otherwise always be true and this
|
||||
// union-specific add would never run.
|
||||
if (prop === 'add') {
|
||||
return function (serverset) {
|
||||
if (!serverset || !(serverset instanceof ServerSet)) {
|
||||
throw new Error('no serverset')
|
||||
}
|
||||
if (target.find(s => serverset.servers.every(s2 => s.servers.includes(s2)))) {
|
||||
console.warn('serverset already in union', serverset)
|
||||
return
|
||||
}
|
||||
target.push(serverset)
|
||||
}
|
||||
}
|
||||
const funcs = Object.getOwnPropertyNames(ServerSet.prototype)
|
||||
if (funcs.includes(prop)) {
|
||||
return async function (...args) {
|
||||
try {
|
||||
return await target.reduce(async (acc, serverset) => {
|
||||
return acc.then(async (acc) => {
|
||||
return acc.concat(await serverset[prop](...args))
|
||||
})
|
||||
}, Promise.resolve([]))
|
||||
} catch (e) {
|
||||
throw new Error('all servers failed')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return target[prop]
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
69
frontend/src/fileCache.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// Session cache for hash-addressed image bytes, deliberately not Vuex. See docs/implementation.md#filecache-design-rationale.
|
||||
|
||||
const MAX_BYTES = 150 * 1024 * 1024; // budget for decoded image bytes before evicting LRU entries
|
||||
|
||||
class FileCache {
|
||||
constructor() {
|
||||
this._entries = new Map(); // key -> {url, size}; Map iteration order doubles as LRU order
|
||||
this._inflight = new Map(); // key -> Promise<string>, de-duplicates concurrent callers
|
||||
this._totalBytes = 0;
|
||||
}
|
||||
|
||||
_touch(key) {
|
||||
// Delete+re-insert moves this entry to the MRU end of the Map's iteration order.
|
||||
const entry = this._entries.get(key);
|
||||
this._entries.delete(key);
|
||||
this._entries.set(key, entry);
|
||||
}
|
||||
|
||||
_evict(key) {
|
||||
const entry = this._entries.get(key);
|
||||
if (!entry) return;
|
||||
URL.revokeObjectURL(entry.url);
|
||||
this._totalBytes -= entry.size;
|
||||
this._entries.delete(key);
|
||||
}
|
||||
|
||||
_evictUntilUnderBudget() {
|
||||
for (const key of this._entries.keys()) {
|
||||
if (this._totalBytes <= MAX_BYTES) break;
|
||||
this._evict(key); // oldest (least recently used) first - Map iterates in insertion order
|
||||
}
|
||||
}
|
||||
|
||||
// fetcher: () => Promise<Blob>, called at most once per key even under concurrent callers.
|
||||
async get(key, fetcher) {
|
||||
if (this._entries.has(key)) {
|
||||
this._touch(key);
|
||||
return this._entries.get(key).url;
|
||||
}
|
||||
if (this._inflight.has(key)) {
|
||||
return this._inflight.get(key);
|
||||
}
|
||||
const promise = (async () => {
|
||||
const blob = await fetcher();
|
||||
const url = URL.createObjectURL(blob);
|
||||
this._entries.set(key, {url, size: blob.size});
|
||||
this._totalBytes += blob.size;
|
||||
this._evictUntilUnderBudget();
|
||||
return url;
|
||||
})();
|
||||
this._inflight.set(key, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
this._inflight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(key) {
|
||||
this._evict(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
for (const key of [...this._entries.keys()]) this._evict(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton - every AuthenticatedImage instance shares the same cache.
|
||||
export default new FileCache();
|
||||
112
frontend/src/identity.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
async function deriveKeyFromPassword(password, salt = null, iter = 100000) {
|
||||
if (!salt) {
|
||||
salt = window.crypto.getRandomValues(new Uint8Array(16))
|
||||
}
|
||||
const pwbuffer = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(password),
|
||||
{name: "PBKDF2"},
|
||||
false,
|
||||
["deriveBits"]
|
||||
);
|
||||
const key_bits = await window.crypto.subtle.deriveBits(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt,
|
||||
iterations: iter,
|
||||
hash: "SHA-256"
|
||||
},
|
||||
pwbuffer,
|
||||
256
|
||||
)
|
||||
const key = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
key_bits,
|
||||
{name: "AES-GCM"},
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
return {key, salt, alg: 'pbkdf2', iter}
|
||||
}
|
||||
|
||||
async function serializeIdentityRecord(user, keypair, password = null) {
|
||||
if (password) {
|
||||
const plain_key = `${nacl.to_hex(keypair.signSk).slice(0, 64)}:${nacl.to_hex(keypair.signPk).slice(56, 64)}`
|
||||
const {key, salt, alg, iter} = await deriveKeyFromPassword(password)
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12))
|
||||
const encrypted_key = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv
|
||||
},
|
||||
key,
|
||||
new TextEncoder('utf-8').encode(plain_key)
|
||||
)
|
||||
return `${user}:$${alg}$${iter}$${nacl.to_hex(salt)}$${nacl.to_hex(iv)}$${nacl.to_hex(new Uint8Array(encrypted_key))}`
|
||||
}
|
||||
return `${user}:${nacl.to_hex(keypair.signSk).slice(0, 64)}:${nacl.to_hex(keypair.signPk).slice(56, 64)}`
|
||||
}
|
||||
|
||||
function parseIdentityRecord(record) {
|
||||
const segments = record.split(':');
|
||||
if (segments.length === 3) {
|
||||
try {
|
||||
const [user, key_hex, sign_hex] = segments
|
||||
const keypair = nacl.crypto_sign_keypair_from_seed(nacl.from_hex(key_hex))
|
||||
const [username, domain] = user.split('@')
|
||||
if (nacl.to_hex(keypair.signPk).slice(56, 64) !== sign_hex || username === '' || domain === '') {
|
||||
return {user: null, keypair: null, errors: ['Invalid identity record']}
|
||||
}
|
||||
return {user, keypair, errors: null}
|
||||
} catch (e) {
|
||||
return {user: null, keypair: null, errors: ['Invalid identity record']}
|
||||
}
|
||||
} else if (segments.length === 2) {
|
||||
try {
|
||||
const [user, rest] = segments
|
||||
if (!rest.startsWith('$')) {
|
||||
return {user: null, keypair: null, errors: ['Invalid identity record']}
|
||||
}
|
||||
const [alg, iter, salt, iv, crypted] = rest.slice(1).split('$')
|
||||
if (alg !== 'pbkdf2') {
|
||||
return {user: null, keypair: null, errors: ['Invalid identity record']}
|
||||
}
|
||||
return {
|
||||
user,
|
||||
alg,
|
||||
iter: parseInt(iter),
|
||||
salt: nacl.from_hex(salt),
|
||||
iv: nacl.from_hex(iv),
|
||||
crypted: nacl.from_hex(crypted),
|
||||
errors: null
|
||||
}
|
||||
} catch (e) {
|
||||
return {user: null, keypair: null, errors: ['Invalid identity record']}
|
||||
}
|
||||
}
|
||||
return {user: null, keypair: null, errors: ['Invalid identity record']}
|
||||
}
|
||||
|
||||
async function decryptIdentityRecord(meta, user, password) {
|
||||
try {
|
||||
const {alg, iter, salt, iv, crypted} = meta
|
||||
if (alg !== 'pbkdf2') {
|
||||
return {user: null, keypair: null, errors: ['Unsupported algorithm']}
|
||||
}
|
||||
const {key} = await deriveKeyFromPassword(password, salt, iter)
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv
|
||||
},
|
||||
key,
|
||||
crypted
|
||||
)
|
||||
const plain_key = new TextDecoder('utf-8').decode(decrypted)
|
||||
return parseIdentityRecord(`${user}:${plain_key}`)
|
||||
}catch (e) {
|
||||
return {user: null, keypair: null, errors: ['could not decrypt identity record']}
|
||||
}
|
||||
}
|
||||
|
||||
export {parseIdentityRecord, serializeIdentityRecord, decryptIdentityRecord}
|
||||
211
frontend/src/label-layouts.js
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import {encodeHandleForUrl} from "@/router";
|
||||
|
||||
// A template's layout tree, content resolution, and selectability contract. See
|
||||
// docs/implementation.md#template-layout-tree.
|
||||
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
|
||||
|
||||
// Single registry for everything that varies per content kind. See docs/implementation.md#content-kinds-registry.
|
||||
export const CONTENT_KINDS = [
|
||||
{
|
||||
id: "item", label: "Item", typedPrefix: "i",
|
||||
shortIdKind: "item", groupShortIdKind: "group_item", localIdField: "item_local_id",
|
||||
buildUrl: ({origin, userHandle, id}) => `${origin}/i/${encodeHandleForUrl(userHandle)}/${id}`,
|
||||
},
|
||||
{
|
||||
id: "storage-location", label: "Storage location", typedPrefix: "s",
|
||||
shortIdKind: "storage_location", groupShortIdKind: "group_storage_location",
|
||||
localIdField: "storage_location_id",
|
||||
buildUrl: ({origin, userHandle, id}) => `${origin}/s/${encodeHandleForUrl(userHandle)}/${id}`,
|
||||
},
|
||||
];
|
||||
|
||||
export const CONTENT_KINDS_BY_ID = Object.fromEntries(CONTENT_KINDS.map(k => [k.id, k]));
|
||||
|
||||
|
||||
// QR-types
|
||||
// qr-l, qr, qr-q, qr-h, mqr-d, mqr-l, mqr, mqr-q, rmqr, rmqr-min, rmqr-max, rmqr-h, rmqr-h-min, rmqr-h-max
|
||||
|
||||
export const LABEL_TEMPLATES = [
|
||||
{
|
||||
id: "mqr-token", name: "MQR Token",
|
||||
description: "Just the code, encoding the compact internal short id - no other text on the label.",
|
||||
required_vars: ["shortId"], tags: ["internal"],
|
||||
layout: [{type: "mqr", content: c => c.shortId}]
|
||||
},
|
||||
{
|
||||
id: "qr-url", name: "QR Token",
|
||||
description: "Just the code, encoding the short link - no other text on the label.",
|
||||
required_vars: ["shortUrl"], tags: ["external"],
|
||||
layout: [{type: "qr-h", content: c => c.shortUrl}]
|
||||
},
|
||||
{
|
||||
id: "rmqr-url", name: "rMQR Token + ID",
|
||||
description: "The code encoding the short link, with the id printed alongside for a human to read.",
|
||||
required_vars: ["shortUrl", "id"], tags: ["external"],
|
||||
layout: [{type: "rmqr", content: c => c.shortUrl}, {
|
||||
type: "text",
|
||||
content: c => c.id?.toString().padStart(4, "0")
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: "rmqr-url-url", name: "rMQR Token + ID + URL",
|
||||
description: "The code encoding the short link, with the id printed alongside for a human to read.",
|
||||
required_vars: ["shortUrl", "id", "typedPrefix", "url"], tags: ["external"],
|
||||
layout: [[[{type: "rmqr", content: c => c.shortUrl}, {
|
||||
type: "text",
|
||||
content: c => c.typedPrefix?.toUpperCase() + c.id?.toString().padStart(4, "0")
|
||||
}],{type: "text", content: c => c.url}]]
|
||||
},
|
||||
{
|
||||
id: "mqr-token-id", name: "MQR Token + ID",
|
||||
description: "The code encoding the internal short id, with the id printed alongside for a human to read.",
|
||||
required_vars: ["shortId", "id"], tags: ["internal"],
|
||||
layout: [{type: "mqr", content: c => c.shortId}, {
|
||||
type: "text",
|
||||
content: c => c.id?.toString().padStart(4, "0")
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: "mqr-token-id", name: "MQR Token + ID",
|
||||
description: "The code encoding the internal short id, with the id printed alongside for a human to read.",
|
||||
required_vars: ["shortId", "id"], tags: ["internal"],
|
||||
layout: [{type: "rmqr", content: c => c.shortId}, {
|
||||
type: "text",
|
||||
content: c => c.typedPrefix?.toUpperCase() + c.id?.toString().padStart(4, "0")
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: "domain-short-id-qr", name: "Any-Instance Token (QR)",
|
||||
description: "The code encoding the domain-qualified short id (domain:~token) - resolvable "
|
||||
+ "from any Toolshed instance's own scanner, not just this one, with no full URL needed.",
|
||||
required_vars: ["domainShortId"], tags: ["cross-instance"],
|
||||
layout: [{type: "qr", content: c => c.domainShortId}]
|
||||
},
|
||||
{
|
||||
id: "domain-short-id-text", name: "Any-Instance Token",
|
||||
description: "Just the domain-qualified short id (domain:~token) as text, not a URL - meant "
|
||||
+ "to be typed or scanned into any Toolshed instance's own resolver.",
|
||||
required_vars: ["domainShortId"], tags: ["cross-instance"],
|
||||
layout: [{type: "text", content: c => c.domainShortId}]
|
||||
},
|
||||
|
||||
{
|
||||
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
|
||||
required_vars: ["text"],
|
||||
layout: [{type: "qr", content: c => c.text}, {type: "text", content: c => c.text?.split("\n")}]
|
||||
},
|
||||
{
|
||||
id: "qr-text-below", name: "QR code + text below",
|
||||
description: "The code with the encoded text printed below it.",
|
||||
required_vars: ["text"],
|
||||
layout: [[{type: "qr", content: c => c.text}, {type: "text", content: c => c.text?.split("\n")}]]
|
||||
},
|
||||
{
|
||||
id: "id-qr-text-vertical", name: "ID + QR code + text below",
|
||||
description: "The code with the encoded text printed below it.",
|
||||
required_vars: ["kind", "id", "text", "userHandle"],
|
||||
layout: [[{type: "text", content: c => c.kind + ": " + c.id}, {
|
||||
type: "qr",
|
||||
content: c => c.text
|
||||
}, {type: "text", content: c => c.userHandle}]]
|
||||
},
|
||||
{
|
||||
id: "qualified-handle", name: "Qualified handle",
|
||||
description: "The compact owner@domain:i<id> / owner@domain:s<id> User-Qualified ID - meaningful "
|
||||
+ "in-app, not scannable on its own.",
|
||||
required_vars: ["qualifiedHandle"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => c.qualifiedHandle}]
|
||||
},
|
||||
{
|
||||
id: "owner-handle", name: "Owner handle", description: "Just the owning user's handle, as text only.",
|
||||
required_vars: ["userHandle"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => c.userHandle}]
|
||||
},
|
||||
{
|
||||
id: "id", name: "ID", description: "Just the bare id, as text only.",
|
||||
required_vars: ["id"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => c.id}]
|
||||
},
|
||||
{
|
||||
id: "owner-id-text-multiline", name: "Owner + ID (same size)",
|
||||
description: "The owner's handle and the id, as two lines of text - no code.",
|
||||
required_vars: ["userHandle", "id"], tags: ["internal"],
|
||||
layout: [{type: "text", content: c => [c.userHandle, c.id]}]
|
||||
},
|
||||
{
|
||||
id: "owner-id-text", name: "Owner + ID",
|
||||
description: "The owner's handle and the id, as two lines of text - no code.",
|
||||
required_vars: ["userHandle", "id"], tags: ["internal"],
|
||||
layout: [[{type: "text", content: c => [c.userHandle]},{type: "text", content: c => [c.id]}]]
|
||||
},
|
||||
{
|
||||
id: "url-qr-handle", name: "URL + handle",
|
||||
description: "Scannable URL, with the compact handle printed alongside.",
|
||||
required_vars: ["url", "qualifiedHandle"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, {type: "text", content: c => c.qualifiedHandle}]
|
||||
},
|
||||
{
|
||||
id: "url-qr-owner", name: "URL + owner",
|
||||
description: "Scannable URL, with the owner's handle printed alongside.",
|
||||
required_vars: ["url", "userHandle"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, {type: "text", content: c => c.userHandle}]
|
||||
},
|
||||
{
|
||||
id: "url-qr-id", name: "URL + ID",
|
||||
description: "Scannable URL, with the bare id printed alongside.",
|
||||
required_vars: ["url", "id"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, {type: "text", content: c => c.id}]
|
||||
},
|
||||
{
|
||||
id: "url-qr-owner-id", name: "URL + owner + ID",
|
||||
description: "Scannable URL, with the owner's handle and the id on two lines alongside.",
|
||||
required_vars: ["url", "userHandle", "id"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, {type: "text", content: c => [c.userHandle, c.id]}]
|
||||
},
|
||||
{
|
||||
id: "short-url-qr", name: "Short link (QR code)",
|
||||
description: "Scannable short link for this item or storage location - more compact than "
|
||||
+ "the full URL. Available for any element with a resolvable short link, not just items.",
|
||||
required_vars: ["shortUrl"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.shortUrl}]
|
||||
},
|
||||
{
|
||||
id: "url-qr-owner-id2", name: "URL + owner + ID",
|
||||
description: "Scannable URL, with the owner's handle and the id on two lines alongside.",
|
||||
required_vars: ["url", "userHandle", "id"], tags: ["external"],
|
||||
layout: [{type: "qr", content: c => c.url}, [{
|
||||
type: "text",
|
||||
content: c => c.userHandle
|
||||
}, {type: "text", content: c => c.id}]]
|
||||
},
|
||||
];
|
||||
|
||||
export const LABEL_TAGS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.tags ?? []))];
|
||||
|
||||
export const KNOWN_VARS = [...new Set(LABEL_TEMPLATES.flatMap(t => t.required_vars))];
|
||||
|
||||
export const DERIVED_VARS = {
|
||||
userHandle: {
|
||||
inputs: ["user", "domain"],
|
||||
calc: (f) => `${f.user}@${f.domain}`,
|
||||
},
|
||||
// handles-and-shortids.md's kind letter (i/s) for f.kind - must stay declared before qualifiedHandle, which depends on it.
|
||||
typedPrefix: {
|
||||
inputs: ["kind"],
|
||||
calc: (f) => CONTENT_KINDS_BY_ID[f.kind]?.typedPrefix,
|
||||
},
|
||||
qualifiedHandle: {
|
||||
inputs: ["userHandle", "typedPrefix", "id"],
|
||||
calc: (f) => `${f.userHandle}:${f.typedPrefix}${f.id}`,
|
||||
},
|
||||
// Undefined for a kind whose CONTENT_KINDS entry has no buildUrl - same "unresolved" signal templateIsAvailable already treats every other missing var as.
|
||||
url: {
|
||||
inputs: ["webdomain", "userHandle", "kind", "id"],
|
||||
calc: (f) => CONTENT_KINDS_BY_ID[f.kind]?.buildUrl?.({origin: f.webdomain, userHandle: f.userHandle, id: f.id}),
|
||||
},
|
||||
};
|
||||
|
||||
export const BASE_VARS = [...new Set([
|
||||
...KNOWN_VARS.filter(v => !(v in DERIVED_VARS)),
|
||||
...Object.values(DERIVED_VARS).flatMap(d => d.inputs).filter(v => !(v in DERIVED_VARS)),
|
||||
])];
|
||||
654
frontend/src/label.js
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
import {loadAnyDCode} from "../vendor/anyd-qr.js";
|
||||
import {drawPixelText, preloadPixelFontRenderer} from "@/pixel-font.js";
|
||||
import {DERIVED_VARS, CONTENT_KINDS_BY_ID} from "@/label-layouts.js";
|
||||
|
||||
// Re-exported so every caller can preload both the QR encoder and the bitmap-font rasterizer
|
||||
// (see fontTierFor's `pixel` tiers below) the same way, alongside this file's own preloadQrEncoder.
|
||||
export {preloadPixelFontRenderer};
|
||||
|
||||
// Mirrors loadAnyDCode()'s memoized wasm instance for synchronous use in buildRenderTree. See docs/implementation.md#qr-encoder-loading.
|
||||
let anyd = null;
|
||||
|
||||
export function preloadQrEncoder() {
|
||||
return loadAnyDCode().then(instance => { anyd = instance; });
|
||||
}
|
||||
|
||||
// Maps label-layouts.js leaf types to anyd-qr.js symbology/ecc/size options, via a naming
|
||||
// convention (plain id = anyd defaults; "-<name>" suffix names an ecc letter or rMQR size
|
||||
// strategy). See docs/implementation.md#qr-leaf-type-mapping.
|
||||
const QR_LEAF_TYPES = {
|
||||
"qr-l": {codeType: "qr", ecc: "L"},
|
||||
qr: {codeType: "qr", ecc: "M"},
|
||||
"qr-q": {codeType: "qr", ecc: "Q"},
|
||||
"qr-h": {codeType: "qr", ecc: "H"},
|
||||
"mqr-d": {codeType: "micro-qr", ecc: "Detection"},
|
||||
"mqr-l": {codeType: "micro-qr", ecc: "L"},
|
||||
mqr: {codeType: "micro-qr", ecc: "M"},
|
||||
"mqr-q": {codeType: "micro-qr", ecc: "Q"},
|
||||
rmqr: {codeType: "rmqr", ecc: "M"},
|
||||
"rmqr-min": {codeType: "rmqr", ecc: "M", size: "min"},
|
||||
"rmqr-max": {codeType: "rmqr", ecc: "M", size: "max"},
|
||||
"rmqr-h": {codeType: "rmqr", ecc: "H"},
|
||||
"rmqr-h-min": {codeType: "rmqr", ecc: "H", size: "min"},
|
||||
"rmqr-h-max": {codeType: "rmqr", ecc: "H", size: "max"},
|
||||
};
|
||||
|
||||
function isQrLeaf(node) {
|
||||
return node.type in QR_LEAF_TYPES;
|
||||
}
|
||||
|
||||
function encodeQr(text, codeType, options) {
|
||||
if (!anyd) {
|
||||
const err = new Error("The QR encoder is still loading — try again in a moment.");
|
||||
err.short = "loading…";
|
||||
throw err;
|
||||
}
|
||||
// BitMatrix-alike shim over anyd's row-major matrix, matching the old "qrcode" package's
|
||||
// modules.size/get() shape that drawQrLeaf/snapQrToCrispSize expect. See
|
||||
// docs/implementation.md#qr-module-matrix-shim.
|
||||
try {
|
||||
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), options).matrix;
|
||||
return {width, height, get: (row, col) => modules[row * width + col] !== 0};
|
||||
} catch (e) {
|
||||
// anyd's own errors (e.g. "capacity exceeded: …") have no `short` of their own - every
|
||||
// failure here comes down to the content not fitting the chosen QR variant's capacity.
|
||||
if (e && typeof e === "object" && !("short" in e)) {
|
||||
e.short = "too long";
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
|
||||
|
||||
/* Sizing a label needs numbers only the driver can supply. */
|
||||
export function tapeFromStatus(status) {
|
||||
const printAreaPx = status?.tape?.printAreaPx;
|
||||
const dpi = status?.printer?.dpi;
|
||||
if (!(printAreaPx > 0) || !(dpi > 0)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
printAreaPx,
|
||||
dpi,
|
||||
mediaWidthMm: status.tape.mediaWidthMm,
|
||||
printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0,
|
||||
/* Brother's documented margin for the mounted tape, in raster columns. */
|
||||
leadPx: status.tape.marginsMm
|
||||
? Math.round(status.tape.marginsMm * dpi / 25.4)
|
||||
: TRAILING_PADDING_PX,
|
||||
};
|
||||
}
|
||||
|
||||
// A layout tree alternates row/column split nodes by nesting depth, with QR/text/empty leaves.
|
||||
// See docs/implementation.md#layout-tree-structure.
|
||||
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
|
||||
|
||||
// Below 33px, a general-purpose sans-serif gets illegible at real label sizes, so drawTextLeaf
|
||||
// switches to one of these bitmap-style fonts instead, rendered through pixel-font.js's
|
||||
// drawPixelText (libfreetype-wasm's own monochrome rasterizer) rather than canvas fillText - see
|
||||
// that file's own comment for why (fillText is always anti-aliased, however carefully its
|
||||
// size/position are snapped, and canvasToBitmap's hard threshold turns that softness into
|
||||
// stray/missing pixels). See the empirical rationale for the font/size choices themselves (and why
|
||||
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The top branch names
|
||||
// Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic
|
||||
// "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the
|
||||
// same label print differently depending on where it was rendered from - Inter is the one tier
|
||||
// still drawn with fillText, since anti-aliasing is expected/fine at this size (see drawTextLeaf).
|
||||
// Checked top-down (first branch whose threshold `fontPx` clears wins) - retune by eye in
|
||||
// prototypes/freetype-wasm/ladder.html before editing this.
|
||||
//
|
||||
// `size` is the actual px handed to the rasterizer - a fixed/snapped value for the bitmap fonts
|
||||
// (they read best at specific pixel sizes, not any arbitrary one - see each branch's comment) or
|
||||
// `fontPx` itself for Inter, which scales cleanly. `fontPx` itself (the layout-derived logical
|
||||
// size) still drives box centering/stacking regardless of what `size` resolves to - same idea as
|
||||
// the old Tom Thumb `scale` correction this replaces, generalized to every tier. `tracking`
|
||||
// (optional, px) retunes glyph spacing (drawPixelText adds it to every glyph's advance) without
|
||||
// touching the rendered glyph size. `pixel: true` routes a tier through drawPixelText instead of
|
||||
// fillText. ladder.html's own copy of this table additionally tunes a `lineHeight` per branch -
|
||||
// meaningful there since it sizes that page's own canvas outright, but deliberately not ported
|
||||
// here: inside drawTextLeaf the box is already sized correctly by the layout engine, so a tier's
|
||||
// tighter lineHeight would only crop real glyph ink (descenders, accented capitals) for no benefit.
|
||||
function fontTierFor(fontPx) {
|
||||
if (fontPx >= 34)
|
||||
return {family: "Inter", size: fontPx};
|
||||
// Terminus reads best at its own hinted sizes rather than whatever fontPx asks for.
|
||||
if (fontPx >= 32)
|
||||
return {family: "Terminus", size: 32, pixel: true};
|
||||
if (fontPx >= 28)
|
||||
return {family: "Terminus", size: 28, pixel: true};
|
||||
if (fontPx >= 24)
|
||||
return {family: "Terminus", size: 24, pixel: true};
|
||||
if (fontPx >= 12)
|
||||
return {family: "Terminus", size: fontPx - (fontPx % 2), pixel: true};
|
||||
if (fontPx >= 10)
|
||||
return {family: "Pixelon", size: 10, tracking: -1, pixel: true};
|
||||
// Uppercase-only - see ../assets/fonts/pixel/LICENSE.md - kept anyway; this tier's narrow.
|
||||
if (fontPx >= 8)
|
||||
return {family: "Chava", size: 8, pixel: true};
|
||||
// TODO find or build better fonts for 6px, 7px and 9px - the criteria would be to make better
|
||||
// use of the height available to be more readable.
|
||||
// Effective ink comes out to ~5px tall at size 8 - see ../assets/fonts/pixel/LICENSE.md.
|
||||
if (fontPx >= 6)
|
||||
return {family: "Silkscreen", size: 8, tracking: -1, pixel: true};
|
||||
// checkTextSizes (see MIN_TEXT_PX) already rejects anything below this floor before
|
||||
// drawTextLeaf ever calls this, so this is a same-file consistency bug, not reachable from
|
||||
// user input.
|
||||
throw new Error(`No font tier covers ${fontPx}px - is MIN_TEXT_PX out of sync?`);
|
||||
}
|
||||
|
||||
// Below this font size (px) or physical height (mm) - fontTierFor's own floor (Silkscreen's
|
||||
// real-Chromium-tested legibility limit; see its `fontPx >= 6` branch above) - text is a hard
|
||||
// failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM
|
||||
// below (see checkTextSizes). No longer just silently blanked.
|
||||
const MIN_TEXT_PX = 6;
|
||||
const MIN_TEXT_MM = 0.5;
|
||||
// Below this, still legible but a soft warning - flagged rather than blocking, same idea as
|
||||
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM.
|
||||
const MIN_RECOMMENDED_TEXT_PX = 8;
|
||||
const MIN_RECOMMENDED_TEXT_MM = 1;
|
||||
|
||||
// Below this many pixels per module, a QR-family code still technically fits (see
|
||||
// snapQrToCrispSize's scale >= 1 hard requirement) but risks blurring together on a real thermal
|
||||
// printer's dot pitch - a soft warning rather than the outright rejection scale < 1 gets.
|
||||
const MIN_RECOMMENDED_QR_PX_PER_MODULE = 3;
|
||||
// Below this per-module physical size (in mm), a QR-family code is a rule-of-thumb risk for a
|
||||
// phone camera to resolve at normal scanning distance even when crisply printed at full
|
||||
// resolution - also a soft warning, independent of the pixels-per-module check above (a printer
|
||||
// can hit that check's px/module floor at any dpi, but only a high enough dpi keeps modules this
|
||||
// physically small still legible).
|
||||
const MIN_RECOMMENDED_QR_MODULE_MM = 0.5;
|
||||
|
||||
function isSplit(node) {
|
||||
return Array.isArray(node);
|
||||
}
|
||||
|
||||
function parseMm(value, key) {
|
||||
const match = typeof value === "string" && /^([\d.]+)mm$/.exec(value);
|
||||
if (!match) {
|
||||
throw new Error(`An "empty" layout node needs a "${key}" like "2mm", got ${JSON.stringify(value)}.`);
|
||||
}
|
||||
return parseFloat(match[1]);
|
||||
}
|
||||
|
||||
// Every node's width/height relate affinely (width = A*height + B, or symmetrically); `ownAxis`
|
||||
// and `wantWidth` pick which direction and against which split axis. See
|
||||
// docs/implementation.md#affine-width-height-relations.
|
||||
function relation(node, ownAxis, wantWidth, pxPerMm) {
|
||||
if (!isSplit(node)) {
|
||||
if (isQrLeaf(node) && node.crispWidth !== undefined) {
|
||||
return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight};
|
||||
}
|
||||
if (isQrLeaf(node) || node.type === "text") {
|
||||
const aspect = node.aspect;
|
||||
return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0};
|
||||
}
|
||||
const key = ownAxis ? "min-width" : "min-height";
|
||||
return {a: 0, b: parseMm(node[key], key) * pxPerMm};
|
||||
}
|
||||
|
||||
const axis = !ownAxis; // this split's own row(true)/column(false) axis
|
||||
const combinesAsWidth = axis; // a row sums widths for a shared height
|
||||
const parts = node.map(child => relation(child, axis, combinesAsWidth, pxPerMm));
|
||||
const a = parts.reduce((sum, p) => sum + p.a, 0);
|
||||
const b = parts.reduce((sum, p) => sum + p.b, 0);
|
||||
if (combinesAsWidth === wantWidth) {
|
||||
return {a, b};
|
||||
}
|
||||
if (a === 0) {
|
||||
// Every child is fixed-size (a === 0) in the combining direction, so inverting would
|
||||
// divide by zero. See docs/implementation.md#fixed-size-relation-edge-case.
|
||||
const otherParts = node.map(child => relation(child, axis, wantWidth, pxPerMm));
|
||||
return {a: 0, b: Math.max(...otherParts.map(p => p.b))};
|
||||
}
|
||||
return {a: 1 / a, b: -b / a}; // invert: solve the affine relation the other way
|
||||
}
|
||||
|
||||
// Top-down: assigns the fixed (width, height) box `node` must exactly fill, recursively, to
|
||||
// every descendant; `ownAxis` carries the same meaning as in relation() above.
|
||||
function layoutTree(node, ownAxis, width, height, pxPerMm) {
|
||||
node.box = {width, height};
|
||||
if (!isSplit(node)) {
|
||||
return;
|
||||
}
|
||||
const axis = !ownAxis;
|
||||
for (const child of node) {
|
||||
if (axis) {
|
||||
const {a, b} = relation(child, axis, true, pxPerMm);
|
||||
layoutTree(child, axis, a * height + b, height, pxPerMm);
|
||||
} else {
|
||||
const {a, b} = relation(child, axis, false, pxPerMm);
|
||||
layoutTree(child, axis, width, a * width + b, pxPerMm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second top-down pass: turns each already-sized box into an absolute (x, y) position; kept
|
||||
// separate from layoutTree since a node's size doesn't depend on its position.
|
||||
function positionTree(node, ownAxis, x, y) {
|
||||
node.box.x = x;
|
||||
node.box.y = y;
|
||||
if (!isSplit(node)) {
|
||||
return;
|
||||
}
|
||||
const axis = !ownAxis;
|
||||
let cursor = axis ? x : y;
|
||||
for (const child of node) {
|
||||
if (axis) {
|
||||
positionTree(child, axis, cursor, y);
|
||||
cursor += child.box.width;
|
||||
} else {
|
||||
positionTree(child, axis, x, cursor);
|
||||
cursor += child.box.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pins each QR-family leaf's real crisp-pixel box.width/box.height so relation() above starts
|
||||
// treating it as fixed-size. Appends a {short, message} entry to `warnings` for each of
|
||||
// MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM the leaf falls short of (still
|
||||
// printable, just flagged as a scan-reliability risk), and unconditionally appends its raw
|
||||
// {scale, moduleMm} to `qrInfo` - callers needing to show those numbers regardless of whether
|
||||
// they tripped a threshold shouldn't have to re-derive them. See
|
||||
// docs/implementation.md#crisp-qr-sizing.
|
||||
function snapQrToCrispSize(node, pxPerMm, warnings, qrInfo) {
|
||||
if (isSplit(node)) {
|
||||
node.forEach(child => snapQrToCrispSize(child, pxPerMm, warnings, qrInfo));
|
||||
return;
|
||||
}
|
||||
if (isQrLeaf(node)) {
|
||||
const {width: modulesW, height: modulesH} = node.qr;
|
||||
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
||||
if (!(scale >= 1)) {
|
||||
const err = new Error("This text needs a bigger code than the tape allows — "
|
||||
+ "try a shorter value or a wider tape.");
|
||||
err.short = "too big";
|
||||
throw err;
|
||||
}
|
||||
node.crispWidth = modulesW * scale;
|
||||
node.crispHeight = modulesH * scale;
|
||||
|
||||
const moduleMm = scale / pxPerMm;
|
||||
qrInfo.push({scale, moduleMm});
|
||||
|
||||
if (scale < MIN_RECOMMENDED_QR_PX_PER_MODULE) {
|
||||
warnings.push({
|
||||
short: `${scale}px/mod`,
|
||||
message: `This code's modules are only ${scale}px wide - they may blur together `
|
||||
+ "when printed; consider a bigger label or shorter content.",
|
||||
});
|
||||
}
|
||||
if (moduleMm < MIN_RECOMMENDED_QR_MODULE_MM) {
|
||||
warnings.push({
|
||||
short: `${moduleMm.toFixed(2)}mm/mod`,
|
||||
message: `This code's modules are only ${moduleMm.toFixed(2)}mm across - it may `
|
||||
+ "be too small to scan reliably; consider a bigger label or shorter content.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rendered font size, floored to a whole pixel - same reasoning as snapQrToCrispSize's
|
||||
// integer module scale (rounding up could overflow the box a fractional size would have fit
|
||||
// exactly). checkTextSizes and drawTextLeaf both call this rather than each computing their own
|
||||
// raw fraction, so what gets measured/threshold-checked is exactly what gets drawn.
|
||||
function effectiveFontPx(node, referencePx) {
|
||||
return Math.floor(referencePx * (node.box.height / node.naturalHeight));
|
||||
}
|
||||
|
||||
// Same shape as snapQrToCrispSize but for "text" leaves: throws below MIN_TEXT_PX/MIN_TEXT_MM
|
||||
// (too small to read at all), appends a {short, message} warning to `warnings` for each of
|
||||
// MIN_RECOMMENDED_TEXT_PX/MIN_RECOMMENDED_TEXT_MM it falls short of, and unconditionally appends
|
||||
// its raw {fontPx, fontMm} to `textInfo`. Must run after the tree's *final* layoutTree pass (unlike
|
||||
// snapQrToCrispSize, which runs before solve() re-resolves the tree) - a text leaf's box.height
|
||||
// isn't stable until then, since (unlike a QR leaf's crispWidth/crispHeight) it doesn't feed back
|
||||
// into that re-resolve.
|
||||
function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) {
|
||||
if (isSplit(node)) {
|
||||
node.forEach(child => checkTextSizes(child, referencePx, pxPerMm, warnings, textInfo));
|
||||
return;
|
||||
}
|
||||
if (node.type !== "text") {
|
||||
return;
|
||||
}
|
||||
const fontPx = effectiveFontPx(node, referencePx);
|
||||
const fontMm = fontPx / pxPerMm;
|
||||
if (fontPx < MIN_TEXT_PX || fontMm < MIN_TEXT_MM) {
|
||||
const err = new Error(`This text only fits at ${fontPx}px `
|
||||
+ `(${fontMm.toFixed(2)}mm) - too small to read; try a shorter value, a different `
|
||||
+ "layout, or a bigger label.");
|
||||
err.short = "too small";
|
||||
throw err;
|
||||
}
|
||||
textInfo.push({fontPx, fontMm});
|
||||
|
||||
if (fontPx < MIN_RECOMMENDED_TEXT_PX) {
|
||||
warnings.push({
|
||||
short: `${fontPx}px text`,
|
||||
message: `This text renders at only ${fontPx}px - it may be hard to read; `
|
||||
+ "consider a bigger label or shorter content.",
|
||||
});
|
||||
}
|
||||
if (fontMm < MIN_RECOMMENDED_TEXT_MM) {
|
||||
warnings.push({
|
||||
short: `${fontMm.toFixed(2)}mm text`,
|
||||
message: `This text renders at only ${fontMm.toFixed(2)}mm tall - it may be hard to `
|
||||
+ "read; consider a bigger label or shorter content.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Always measures in Inter at the reference size, since the eventual font (see fontTierFor) isn't
|
||||
// known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when
|
||||
// a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still
|
||||
// catches real failures.
|
||||
function measureTextBlock(ctx, lines, referencePx) {
|
||||
ctx.font = `${referencePx}px "Inter"`;
|
||||
const width = Math.max(...lines.map(line => ctx.measureText(line).width));
|
||||
const height = referencePx * 1.15 * lines.length;
|
||||
return {width, height};
|
||||
}
|
||||
|
||||
// Converts a resolved content tree (see templateContent below) into one ready for layout. See
|
||||
// docs/implementation.md#render-tree-construction.
|
||||
function buildRenderTree(ctx, node, referencePx) {
|
||||
if (isSplit(node)) {
|
||||
return node.map(child => buildRenderTree(ctx, child, referencePx));
|
||||
}
|
||||
if (isQrLeaf(node)) {
|
||||
const {codeType, ...options} = QR_LEAF_TYPES[node.type];
|
||||
const qr = encodeQr(node.value, codeType, options);
|
||||
return {type: node.type, aspect: qr.width / qr.height, qr};
|
||||
}
|
||||
if (node.type === "text") {
|
||||
const lines = Array.isArray(node.value) ? node.value : [node.value];
|
||||
const {width, height} = measureTextBlock(ctx, lines, referencePx);
|
||||
return {type: "text", aspect: width / height, naturalHeight: height, lines};
|
||||
}
|
||||
return {type: "empty", "min-width": node["min-width"], "min-height": node["min-height"]};
|
||||
}
|
||||
|
||||
function drawQrLeaf(ctx, node) {
|
||||
const {width: modulesW, height: modulesH} = node.qr;
|
||||
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
|
||||
const w = modulesW * scale;
|
||||
const h = modulesH * scale;
|
||||
const left = node.box.x + Math.floor((node.box.width - w) / 2);
|
||||
const top = node.box.y + Math.floor((node.box.height - h) / 2);
|
||||
for (let row = 0; row < modulesH; row++) {
|
||||
for (let col = 0; col < modulesW; col++) {
|
||||
if (node.qr.get(row, col)) {
|
||||
ctx.fillRect(left + col * scale, top + row * scale, scale, scale);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the effective (un-normalized) font size drawn at; drawTree collects these into
|
||||
// drawLabel/drawFallbackLabel's textSizesPx. Always draws - checkTextSizes (run earlier, on the
|
||||
// same tree, before any of this) already rejected anything below MIN_TEXT_PX/MIN_TEXT_MM, so
|
||||
// there's no "too small to draw" case left to special-case here.
|
||||
function drawTextLeaf(ctx, node, referencePx) {
|
||||
const fontPx = effectiveFontPx(node, referencePx);
|
||||
const tier = fontTierFor(fontPx);
|
||||
if (tier.pixel) {
|
||||
// Own rasterizer, own centering/pixel-snapping, own multi-line stacking - see
|
||||
// pixel-font.js's drawPixelText for why (and its own doc comment for what it does with
|
||||
// node.box/node.lines, the same inputs drawTextLeaf itself would otherwise use below).
|
||||
drawPixelText(ctx, tier, node.lines, node.box);
|
||||
return fontPx;
|
||||
}
|
||||
// Only Inter reaches here (fontTierFor's non-pixel tier) - canvas fillText's anti-aliasing is
|
||||
// expected/fine at this size, unlike the bitmap tiers above (see pixel-font.js/fontTierFor).
|
||||
ctx.font = `${tier.size}px "${tier.family}"`;
|
||||
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
|
||||
// text. See docs/implementation.md#canvas-font-loading.
|
||||
document.fonts.load(ctx.font);
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
const centerX = node.box.x + node.box.width / 2;
|
||||
const lineHeight = node.box.height / node.lines.length;
|
||||
// Lines stack as a block, each centered under the last, so a multi-line field reads as one unit.
|
||||
let sliceTop = node.box.y;
|
||||
for (const line of node.lines) {
|
||||
ctx.fillText(line, centerX, sliceTop + lineHeight / 2);
|
||||
sliceTop += lineHeight;
|
||||
}
|
||||
return fontPx;
|
||||
}
|
||||
|
||||
// Manual debug toggle (flip in a debugger) to outline every leaf's box, including
|
||||
// normally-invisible "empty" ones, in a color that can't be mistaken for real label ink; never
|
||||
// wired up to any UI.
|
||||
let DEBUG_LEAF_BORDERS = false;
|
||||
|
||||
function drawDebugBorder(ctx, node) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "red";
|
||||
ctx.lineWidth = 1;
|
||||
// Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the edge.
|
||||
ctx.strokeRect(node.box.x + 0.5, node.box.y + 0.5, node.box.width - 1, node.box.height - 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Collects each text leaf's effective font size so callers (Print.vue) can spot a suspiciously
|
||||
// small field (checkTextSizes' warnings/textInfo cover the same numbers in more structured form).
|
||||
function drawTree(ctx, node, referencePx, textSizesPx) {
|
||||
if (isSplit(node)) {
|
||||
node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx));
|
||||
return;
|
||||
}
|
||||
if (isQrLeaf(node)) {
|
||||
drawQrLeaf(ctx, node);
|
||||
} else if (node.type === "text") {
|
||||
textSizesPx.push(drawTextLeaf(ctx, node, referencePx));
|
||||
}
|
||||
// "empty" leaves carry no ink - their box just reserves the space.
|
||||
if (DEBUG_LEAF_BORDERS) {
|
||||
drawDebugBorder(ctx, node);
|
||||
}
|
||||
}
|
||||
|
||||
// Builds, sizes and validates the tree for a fixed dimension plus pxPerMm; runs sizing twice so
|
||||
// QR-family leaves' real crisp size is known before the tree is finally resolved, then checks
|
||||
// every text leaf's final font size. See docs/implementation.md#label-content-layout.
|
||||
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) {
|
||||
const tree = buildRenderTree(ctx, content, referencePx);
|
||||
const alongTape = orientation !== "across";
|
||||
|
||||
const solve = () => {
|
||||
const {a, b} = relation(tree, false, alongTape, pxPerMm);
|
||||
return alongTape
|
||||
? {width: a * fixedSize + b, height: fixedSize}
|
||||
: {width: fixedSize, height: a * fixedSize + b};
|
||||
};
|
||||
|
||||
let {width, height} = solve();
|
||||
layoutTree(tree, false, width, height, pxPerMm);
|
||||
const warnings = [];
|
||||
const qrInfo = [];
|
||||
snapQrToCrispSize(tree, pxPerMm, warnings, qrInfo);
|
||||
|
||||
({width, height} = solve());
|
||||
const length = alongTape ? width : height;
|
||||
if (maxLength !== Infinity && length > maxLength) {
|
||||
const err = new Error("This doesn't fit on this tape — "
|
||||
+ "try a shorter value, a different layout, or a bigger label.");
|
||||
err.short = "too big";
|
||||
throw err;
|
||||
}
|
||||
layoutTree(tree, false, width, height, pxPerMm);
|
||||
const textInfo = [];
|
||||
checkTextSizes(tree, referencePx, pxPerMm, warnings, textInfo);
|
||||
return {tree, length, warnings, qrInfo, textInfo};
|
||||
}
|
||||
|
||||
// The tape-fed layout: draws a fully resolved content tree (see templateContent below) at the tape's
|
||||
// real pixel dimensions. See docs/implementation.md#tape-fed-label-drawing. Returns
|
||||
// {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
|
||||
// top-to-bottom order; {warnings}: {short, message} scan-reliability entries from
|
||||
// snapQrToCrispSize/checkTextSizes, if any; {qrInfo}: each QR-family leaf's raw
|
||||
// {scale, moduleMm}; {textInfo}: each text leaf's raw {fontPx, fontMm} - both regardless of
|
||||
// whether they tripped a warning.
|
||||
export function drawLabel(canvas, tape, content, orientation = "along") {
|
||||
const maxLength = tape.printLengthPx
|
||||
? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX
|
||||
: Infinity;
|
||||
const measureCtx = canvas.getContext("2d");
|
||||
const pxPerMm = tape.dpi / 25.4;
|
||||
const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent(
|
||||
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation);
|
||||
|
||||
const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX);
|
||||
canvas.width = printedLength;
|
||||
canvas.height = tape.printAreaPx;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
// Defensive, not load-bearing: drawPixelText's own blits already land on integer pixels (see
|
||||
// its own comment), but a 1:1 drawImage getting resampled by an engine quirk would reintroduce
|
||||
// exactly the anti-aliasing canvasToBitmap's hard threshold turns into stray pixels.
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, printedLength, canvas.height);
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
const origin = tape.leadPx
|
||||
+ Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
|
||||
const textSizesPx = [];
|
||||
if (orientation === "across") {
|
||||
// Rotates/translates the width-fixed tree into the physical raster a quarter turn at a
|
||||
// time. See docs/implementation.md#across-orientation-rotation.
|
||||
positionTree(tree, false, 0, origin);
|
||||
ctx.save();
|
||||
ctx.translate(0, tape.printAreaPx);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||
ctx.restore();
|
||||
} else {
|
||||
positionTree(tree, false, origin, 0);
|
||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||
}
|
||||
return {textSizesPx, warnings, qrInfo, textInfo};
|
||||
}
|
||||
|
||||
const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */
|
||||
const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' mm sizes into px */
|
||||
|
||||
// The no-webusb preview/PNG: same layout tree/renderer as drawLabel, scaled from a fixed
|
||||
// reference height instead. See docs/implementation.md#fallback-label-preview. `orientation` and
|
||||
// the {textSizesPx, warnings, qrInfo, textInfo} return, see drawLabel.
|
||||
export function drawFallbackLabel(canvas, content, orientation = "along") {
|
||||
const measureCtx = canvas.getContext("2d");
|
||||
const pxPerMm = FALLBACK_DPI / 25.4;
|
||||
const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent(
|
||||
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation);
|
||||
|
||||
canvas.width = Math.ceil(contentLength);
|
||||
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
|
||||
|
||||
const ctx = canvas.getContext("2d", {willReadFrequently: true});
|
||||
ctx.imageSmoothingEnabled = false; /* see drawLabel's own comment */
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#000";
|
||||
|
||||
const textSizesPx = [];
|
||||
if (orientation === "across") {
|
||||
positionTree(tree, false, 0, 0);
|
||||
ctx.save();
|
||||
ctx.translate(0, FALLBACK_LABEL_HEIGHT_PX);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||
ctx.restore();
|
||||
} else {
|
||||
positionTree(tree, false, 0, 0);
|
||||
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
|
||||
}
|
||||
return {textSizesPx, warnings, qrInfo, textInfo};
|
||||
}
|
||||
|
||||
// Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a print label should show/encode, via that kind's CONTENT_KINDS_BY_ID entry.
|
||||
export function buildLabelContent(prefill) {
|
||||
const entry = prefill && CONTENT_KINDS_BY_ID[prefill.kind];
|
||||
const {userHandle, id} = prefill?.components ?? {};
|
||||
if (!entry?.buildUrl || !userHandle || !id) {
|
||||
return "";
|
||||
}
|
||||
return entry.buildUrl({origin: window.location.origin, userHandle, id});
|
||||
}
|
||||
|
||||
// Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way store.js's lookupServer does).
|
||||
function splitUserHandle(userHandle) {
|
||||
if (!userHandle) {
|
||||
return null;
|
||||
}
|
||||
const at = userHandle.indexOf("@");
|
||||
return {
|
||||
user: at === -1 ? userHandle : userHandle.slice(0, at),
|
||||
domain: at === -1 ? "" : userHandle.slice(at + 1),
|
||||
};
|
||||
}
|
||||
|
||||
// Seeds label-layouts.js's BASE_VARS from a {kind, components: {userHandle, id}} prefill - one generic function suffices now that every kind's prefill shares this shape (see CONTENT_KINDS_BY_ID).
|
||||
export function buildLabelFields(prefill) {
|
||||
if (!prefill || !(prefill.kind in CONTENT_KINDS_BY_ID)) {
|
||||
return {};
|
||||
}
|
||||
const {userHandle, id} = prefill.components ?? {};
|
||||
const split = splitUserHandle(userHandle);
|
||||
if (!split || !id) {
|
||||
return {};
|
||||
}
|
||||
return {...split, kind: prefill.kind, id: String(id)};
|
||||
}
|
||||
|
||||
// Runs each DERIVED_VARS calc against `fields`, adding the result wherever its inputs are
|
||||
// present, so callers never need to know DERIVED_VARS' {inputs, calc} shape.
|
||||
export function withDerivedVars(fields) {
|
||||
const result = {...fields};
|
||||
for (const [name, {inputs, calc}] of Object.entries(DERIVED_VARS)) {
|
||||
if (inputs.every(v => result[v])) {
|
||||
result[name] = calc(result);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function walkLeaves(node, fn) {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(child => walkLeaves(child, fn));
|
||||
} else {
|
||||
fn(node);
|
||||
}
|
||||
}
|
||||
|
||||
function mapTree(node, fn) {
|
||||
return Array.isArray(node) ? node.map(child => mapTree(child, fn)) : fn(node);
|
||||
}
|
||||
|
||||
// Resolved only if every part is - a single string for a QR-family/"text" leaf, every line for a
|
||||
// multi-line "text" (see label-layouts.js's "owner-id-text" and "item-url-qr-owner-id" templates).
|
||||
function isResolved(value) {
|
||||
return Array.isArray(value) ? value.every(isResolved) : value !== undefined && value !== null;
|
||||
}
|
||||
|
||||
// Shared by LabelLayoutPreview.vue's grid and Print.vue's preview, so neither reimplements
|
||||
// leaf-walking/field-resolving itself.
|
||||
export function templateIsAvailable(t, fields) {
|
||||
let available = true;
|
||||
walkLeaves(t.layout, leaf => {
|
||||
if (leaf.type !== "empty" && !isResolved(leaf.content(fields))) {
|
||||
available = false;
|
||||
}
|
||||
});
|
||||
return available;
|
||||
}
|
||||
|
||||
export function templateContent(t, fields) {
|
||||
if (!templateIsAvailable(t, fields)) {
|
||||
const err = new Error("This layout needs more fields filled in before it can be drawn.");
|
||||
err.short = "missing data";
|
||||
throw err;
|
||||
}
|
||||
return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import {createApp} from 'vue'
|
||||
|
||||
import {BootstrapIconsPlugin} from 'bootstrap-icons-vue';
|
||||
import App from './App.vue'
|
||||
|
||||
|
|
@ -9,6 +10,8 @@ import store from './store';
|
|||
|
||||
import _nacl from 'js-nacl';
|
||||
|
||||
|
||||
|
||||
const app = createApp(App).use(store).use(BootstrapIconsPlugin);
|
||||
|
||||
_nacl.instantiate((nacl) => {
|
||||
|
|
@ -16,6 +19,8 @@ _nacl.instantiate((nacl) => {
|
|||
app.use(router).mount('#app')
|
||||
});
|
||||
|
||||
//app.component(VueQrcode.name, VueQrcode);
|
||||
|
||||
window.closeAllDropdowns = function () {
|
||||
const dropdowns = document.getElementsByClassName("dropdown-menu");
|
||||
let i;
|
||||
|
|
@ -27,6 +32,7 @@ window.closeAllDropdowns = function () {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
window.onclick = function (event) {
|
||||
if (!event.target.matches('.dropdown-toggle *')
|
||||
&& !event.target.matches('.dropdown-toggle')
|
||||
|
|
@ -39,7 +45,7 @@ window.onclick = function (event) {
|
|||
&& !event.target.matches('.sidebar *')
|
||||
&& !event.target.matches('.sidebar')) {
|
||||
const sidebar = document.getElementById("sidebar");
|
||||
const marginLeft = parseInt(getComputedStyle(sidebar).marginLeft);
|
||||
const marginLeft = parseInt(window.getComputedStyle(sidebar).marginLeft);
|
||||
if (sidebar.classList.contains('collapsed') && marginLeft === 0) {
|
||||
sidebar.classList.remove('collapsed');
|
||||
}
|
||||
|
|
|
|||
209
frontend/src/pixel-font.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
// Renders label.js's bitmap-style FONT_TIERS entries (Terminus/Pixelon/Chava/Silkscreen) through
|
||||
// libfreetype-wasm's own monochrome rasterizer (FT_LOAD_TARGET_MONO) instead of canvas fillText.
|
||||
// fillText is always anti-aliased, however carefully its size/position are snapped to the pixel
|
||||
// grid - Canvas 2D has no monochrome/hinted-bitmap text mode - and that soft edge becomes
|
||||
// stray/missing pixels once canvasToBitmap's hard threshold runs on it. Rendering through
|
||||
// FreeType's own 1-bit rasterizer and blitting the result at integer pixel offsets removes both
|
||||
// problems at the source instead of trying to compensate for them after the fact. See
|
||||
// prototypes/freetype-wasm/README.md and docs/implementation.md#pixel-font-selection.
|
||||
|
||||
import terminusFontUrl from "@/assets/fonts/pixel/Terminus.ttf?url";
|
||||
import pixelonFontUrl from "@/assets/fonts/pixel/Pixelon.ttf?url";
|
||||
import chavaFontUrl from "@/assets/fonts/pixel/Chava.ttf?url";
|
||||
import silkscreenFontUrl from "@/assets/fonts/pixel/Silkscreen.ttf?url";
|
||||
|
||||
// Every family fontTierFor (label.js) can pick with `pixel: true` - fetched once by
|
||||
// preloadPixelFontRenderer, not lazily, so a first draw never races the font's own fetch.
|
||||
const FONT_URLS = {
|
||||
Terminus: terminusFontUrl,
|
||||
Pixelon: pixelonFontUrl,
|
||||
Chava: chavaFontUrl,
|
||||
Silkscreen: silkscreenFontUrl,
|
||||
};
|
||||
|
||||
// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
|
||||
const FREETYPE_URL = "/vendor/freetype.js";
|
||||
|
||||
// Vite refuses to import anything under public/ from source (see the long comment this is copied
|
||||
// from, originally in freetype-preview.js) - an inline `<script type="module">` sidesteps both
|
||||
// that and the CSP's blob:-URL restriction, since the browser resolves its `import` as an ordinary
|
||||
// top-level fetch of `url`, same as a real HTML tag would.
|
||||
let importCounter = 0;
|
||||
|
||||
async function importPublicModule(url) {
|
||||
const absoluteUrl = new URL(url, document.baseURI).href;
|
||||
const callbackName = `__importPublicModule_${importCounter++}`;
|
||||
return await new Promise((resolve, reject) => {
|
||||
window[callbackName] = (mod) => {
|
||||
delete window[callbackName];
|
||||
resolve(mod);
|
||||
};
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
script.textContent = `import * as m from ${JSON.stringify(absoluteUrl)}; `
|
||||
+ `window.${callbackName}(m);`;
|
||||
script.onerror = () => {
|
||||
delete window[callbackName];
|
||||
reject(new Error(`failed to load ${absoluteUrl} as a module`));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
script.remove();
|
||||
});
|
||||
}
|
||||
|
||||
let Module = null;
|
||||
let modulePromise = null;
|
||||
const fontBytes = {}; /* family -> Uint8Array, populated once by preload */
|
||||
let loadedFamily = null; /* which family's face is currently active in the wasm module's single slot */
|
||||
|
||||
// Loads freetype.wasm and every FONT_URLS font's bytes up front - called once from each entry
|
||||
// point (Print.vue/LabelLayoutPreview.vue's mounted(), alongside preloadQrEncoder) so drawPixelText
|
||||
// never has to await anything mid-draw; label.js's drawTree is synchronous throughout. Memoized the
|
||||
// same way label.js's own preloadQrEncoder memoizes anyd - callers can call this freely.
|
||||
export function preloadPixelFontRenderer() {
|
||||
modulePromise ??= (async () => {
|
||||
const [{default: FreeTypeModule}] = await Promise.all([
|
||||
importPublicModule(FREETYPE_URL),
|
||||
...Object.entries(FONT_URLS).map(async ([family, url]) => {
|
||||
const res = await fetch(url);
|
||||
fontBytes[family] = new Uint8Array(await res.arrayBuffer());
|
||||
}),
|
||||
]);
|
||||
const mod = await FreeTypeModule();
|
||||
const rc = mod.ccall("ft_init", "number", [], []);
|
||||
if (rc !== 0) {
|
||||
throw new Error(`FT_Init_FreeType failed (error ${rc})`);
|
||||
}
|
||||
Module = mod;
|
||||
})();
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
// Copies `family`'s bytes into the wasm heap and hands them to FT_New_Memory_Face, skipped if
|
||||
// that family's face is already the one loaded - the module holds exactly one face at a time (see
|
||||
// wrapper.c's ft_load_font), and a label typically draws several leaves in a row that often share
|
||||
// a tier/family.
|
||||
function ensureFace(family) {
|
||||
if (loadedFamily === family) {
|
||||
return;
|
||||
}
|
||||
const bytes = fontBytes[family];
|
||||
const ptr = Module._malloc(bytes.length);
|
||||
Module.HEAPU8.set(bytes, ptr);
|
||||
const rc = Module.ccall("ft_load_font", "number", ["number", "number"], [ptr, bytes.length]);
|
||||
Module._free(ptr);
|
||||
if (rc !== 0) {
|
||||
throw new Error(`FreeType failed to parse the ${family} font (error ${rc})`);
|
||||
}
|
||||
loadedFamily = family;
|
||||
}
|
||||
|
||||
// Blits one glyph's rasterized bitmap onto `ctx` at (x, y), y being the baseline - same shape as
|
||||
// prototypes/freetype-wasm's own drawGlyphBitmap. Handles both pixel modes FT_LOAD_TARGET_MONO can
|
||||
// still yield - FT_PIXEL_MODE_MONO (1 bit/pixel, the expected case) and FT_PIXEL_MODE_GRAY (a
|
||||
// glyph FreeType has no mono rasterizer path for, e.g. some embedded-bitmap formats).
|
||||
function drawGlyphBitmap(ctx, x, y) {
|
||||
const width = Module.ccall("ft_get_bitmap_width", "number", [], []);
|
||||
const height = Module.ccall("ft_get_bitmap_height", "number", [], []);
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
const pitch = Module.ccall("ft_get_bitmap_pitch", "number", [], []);
|
||||
const left = Module.ccall("ft_get_bitmap_left", "number", [], []);
|
||||
const top = Module.ccall("ft_get_bitmap_top", "number", [], []);
|
||||
const pixelMode = Module.ccall("ft_get_pixel_mode", "number", [], []);
|
||||
const bufferPtr = Module.ccall("ft_get_bitmap_buffer", "number", [], []);
|
||||
|
||||
const src = Module.HEAPU8.subarray(bufferPtr, bufferPtr + pitch * height);
|
||||
const imageData = new ImageData(width, height);
|
||||
const dst = imageData.data;
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
let alpha;
|
||||
if (pixelMode === 1) { // FT_PIXEL_MODE_MONO: 1 bit per pixel, MSB first
|
||||
const byte = src[row * pitch + (col >> 3)];
|
||||
alpha = (byte & (0x80 >> (col & 7))) ? 255 : 0;
|
||||
} else { // FT_PIXEL_MODE_GRAY: 1 byte per pixel, 0-255 coverage
|
||||
alpha = src[row * pitch + col];
|
||||
}
|
||||
const di = (row * width + col) * 4;
|
||||
dst[di] = dst[di + 1] = dst[di + 2] = 0;
|
||||
dst[di + 3] = alpha;
|
||||
}
|
||||
}
|
||||
const tmp = document.createElement("canvas");
|
||||
tmp.width = width;
|
||||
tmp.height = height;
|
||||
tmp.getContext("2d").putImageData(imageData, 0, 0);
|
||||
// x/y are already integers (see drawPixelText) and this is an unscaled 1:1 blit, so this never
|
||||
// resamples/blurs regardless - imageSmoothingEnabled is off on the destination anyway (see
|
||||
// drawLabel/drawFallbackLabel) as a second, defensive line against that.
|
||||
ctx.drawImage(tmp, x + left, y - top);
|
||||
}
|
||||
|
||||
// Renders `codes` (an array of codepoints) at the current face/pixel-size without drawing
|
||||
// anything, purely to learn the line's total advance width and real ink extents (max ascent/
|
||||
// descent actually reached by these specific glyphs, not the font's generic metrics) - the same
|
||||
// two-metric shape drawTextLeaf's old ctx.measureText(line).actualBoundingBox{Ascent,Descent} gave
|
||||
// it, needed up front to center this line before any glyph is drawn.
|
||||
function measureLine(codes, tracking) {
|
||||
let width = 0;
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
for (const code of codes) {
|
||||
if (Module.ccall("ft_render_glyph", "number", ["number", "number"], [code, 1]) !== 0) {
|
||||
continue;
|
||||
}
|
||||
width += Module.ccall("ft_get_advance_x", "number", [], []) + tracking;
|
||||
const height = Module.ccall("ft_get_bitmap_height", "number", [], []);
|
||||
if (height === 0) {
|
||||
continue;
|
||||
}
|
||||
const top = Module.ccall("ft_get_bitmap_top", "number", [], []);
|
||||
up = Math.max(up, top);
|
||||
down = Math.max(down, height - top);
|
||||
}
|
||||
return {width, up, down};
|
||||
}
|
||||
|
||||
// Draws `codes` starting at the integer pen position (x, baseline), advancing by each glyph's real
|
||||
// width plus `tracking` (px, may be negative - lets neighboring glyphs overlap rather than
|
||||
// resizing them, same idea as ladder.html's own tracking).
|
||||
function drawLine(ctx, codes, x, baseline, tracking) {
|
||||
for (const code of codes) {
|
||||
if (Module.ccall("ft_render_glyph", "number", ["number", "number"], [code, 1]) !== 0) {
|
||||
continue;
|
||||
}
|
||||
drawGlyphBitmap(ctx, x, baseline);
|
||||
x += Module.ccall("ft_get_advance_x", "number", [], []) + tracking;
|
||||
}
|
||||
}
|
||||
|
||||
// Draws `lines` (already split - see label.js's node.lines) through FreeType's own rasterizer,
|
||||
// stacked one per `box.height / lines.length` row and each centered (both axes) within its row -
|
||||
// same centering semantics drawTextLeaf's pixel-font branch used with ctx.fillText, just measured
|
||||
// via FreeType's own bitmap metrics instead of canvas's actualBoundingBox. `box.x`/`width` may be
|
||||
// ordinary fractional layout numbers; every actual draw coordinate here is rounded to a whole
|
||||
// pixel first; a bitmap font blitted at a fractional offset would blur exactly like fillText does.
|
||||
// Returns the face's line height in px at `size` (ft_get_line_height), for parity with the fontPx
|
||||
// drawTextLeaf itself already returns/reports.
|
||||
export function drawPixelText(ctx, {family, size, tracking = 0}, lines, box) {
|
||||
if (!Module) {
|
||||
throw new Error("The bitmap font renderer is still loading — try again in a moment.");
|
||||
}
|
||||
ensureFace(family);
|
||||
Module.ccall("ft_set_pixel_size", "number", ["number"], [Math.round(size)]);
|
||||
const lineHeight = Module.ccall("ft_get_line_height", "number", [], []);
|
||||
|
||||
const rowHeight = box.height / lines.length;
|
||||
let sliceTop = box.y;
|
||||
for (const line of lines) {
|
||||
const codes = Array.from(line).map(ch => ch.codePointAt(0));
|
||||
const {width, up, down} = measureLine(codes, tracking);
|
||||
const x = Math.round(box.x + box.width / 2 - width / 2);
|
||||
const baseline = Math.round(sliceTop + (rowHeight + up - down) / 2);
|
||||
drawLine(ctx, codes, x, baseline, tracking);
|
||||
sliceTop += rowHeight;
|
||||
}
|
||||
return lineHeight;
|
||||
}
|
||||
89
frontend/src/preferences.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// Preference schema catalog, device/account resolution, and device-local (localStorage)
|
||||
// persistence. See docs/implementation.md#preferences for account vs device precedence.
|
||||
|
||||
export const DEVICE_STORAGE_KEY = 'toolshed.preferences.device'
|
||||
|
||||
export const defaultPreferenceDefinitions = [
|
||||
{
|
||||
key: 'ui.compact_mode',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
label: 'Compact mode',
|
||||
description: 'Show denser item rows and reduce spacing in lists.'
|
||||
},
|
||||
{
|
||||
key: 'ui.default_search_scope',
|
||||
type: 'enum',
|
||||
options: ['inventory', 'friends', 'all'],
|
||||
default: 'inventory',
|
||||
label: 'Default search scope',
|
||||
description: 'Choose where the global search starts.'
|
||||
},
|
||||
{
|
||||
key: 'notifications.desktop_enabled',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
label: 'Desktop notifications',
|
||||
description: 'Enable in-browser notifications for important updates.'
|
||||
},
|
||||
{
|
||||
key: 'files.max_upload_mb',
|
||||
type: 'integer',
|
||||
default: 25,
|
||||
label: 'Default upload size limit (MB)',
|
||||
description: 'Used as a prefill hint in upload dialogs.'
|
||||
},
|
||||
{
|
||||
key: 'ui.experimental_flags',
|
||||
type: 'json',
|
||||
default: {},
|
||||
label: 'Experimental flags',
|
||||
description: 'Optional JSON toggles for feature previews.'
|
||||
},
|
||||
]
|
||||
|
||||
export function loadDevicePreferences(storageKey = DEVICE_STORAGE_KEY) {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch (_error) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort - a disabled/full localStorage shouldn't break whatever triggered the save (see
|
||||
// e.g. Print.vue's rememberLayout).
|
||||
export function saveDevicePreferences(preferences, storageKey = DEVICE_STORAGE_KEY) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(preferences))
|
||||
} catch (error) {
|
||||
console.error('Failed to save device preferences:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution order: device -> account -> schema default -> fallbackDefault.
|
||||
export function resolvePreference(key, {devicePreferences = {}, accountPreferences = {}, definitions = []} = {}, fallbackDefault = null) {
|
||||
if (Object.prototype.hasOwnProperty.call(devicePreferences, key) && devicePreferences[key] !== null) {
|
||||
return devicePreferences[key]
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(accountPreferences, key) && accountPreferences[key] !== null) {
|
||||
return accountPreferences[key]
|
||||
}
|
||||
const definition = definitions.find((pref) => pref.key === key)
|
||||
if (definition) {
|
||||
return definition.default
|
||||
}
|
||||
return fallbackDefault
|
||||
}
|
||||
|
||||
export default {
|
||||
DEVICE_STORAGE_KEY,
|
||||
defaultPreferenceDefinitions,
|
||||
loadDevicePreferences,
|
||||
saveDevicePreferences,
|
||||
resolvePreference,
|
||||
};
|
||||
66
frontend/src/printerManager.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Shared printer device manager for WebUSB label printers (Print.vue) - centralizes device
|
||||
// enumeration and preferred-printer memory, mirroring cameraManager.js's approach for webcams.
|
||||
class PrinterManager {
|
||||
constructor() {
|
||||
this.availableDevices = [];
|
||||
}
|
||||
|
||||
// A USB device has no stable "deviceId" like MediaDeviceInfo - vendorId+productId+serialNumber
|
||||
// is the closest stable identity across plug/unplug and page reloads (empty serial number still
|
||||
// uniquely identifies the common single-printer-of-that-model setup).
|
||||
printerId(device) {
|
||||
return `${device.vendorId}:${device.productId}:${device.serialNumber || ''}`;
|
||||
}
|
||||
|
||||
async enumerateDevices() {
|
||||
try {
|
||||
this.availableDevices = await navigator.usb.getDevices();
|
||||
return this.availableDevices;
|
||||
} catch (err) {
|
||||
console.error('Error enumerating printers:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getAvailableDevices() {
|
||||
return this.availableDevices;
|
||||
}
|
||||
|
||||
getRecentPrinters() {
|
||||
try {
|
||||
const saved = localStorage.getItem('recentPrinterIds');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch (err) {
|
||||
console.error('Error loading recent printers:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
savePreferredPrinter(device) {
|
||||
const printerId = this.printerId(device);
|
||||
const recentPrinters = this.getRecentPrinters();
|
||||
const updated = [printerId, ...recentPrinters.filter((id) => id !== printerId)];
|
||||
|
||||
try {
|
||||
localStorage.setItem('recentPrinterIds', JSON.stringify(updated));
|
||||
} catch (err) {
|
||||
console.error('Error saving recent printers:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Most-recently-used device that's among `devices` (already-paired printers currently
|
||||
// visible), for auto-connecting on page load / when a printer is plugged back in - same
|
||||
// "recent list, first still-present match wins" logic as cameraManager's loadPreferredCamera.
|
||||
findPreferredDevice(devices) {
|
||||
const recentPrinters = this.getRecentPrinters();
|
||||
for (const printerId of recentPrinters) {
|
||||
const device = devices.find((d) => this.printerId(d) === printerId);
|
||||
if (device) {
|
||||
return device;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default new PrinterManager();
|
||||
|
|
@ -1,32 +1,278 @@
|
|||
import {createRouter, createWebHistory} from 'vue-router'
|
||||
import Index from '@/views/Index.vue';
|
||||
import Dashboard from '@/views/Dashboard.vue';
|
||||
import Login from '@/views/Login.vue';
|
||||
import Register from '@/views/Register.vue';
|
||||
import Pairing from '@/views/Pairing.vue';
|
||||
import store from '@/store';
|
||||
import Profile from '@/views/Profile.vue';
|
||||
import Settings from '@/views/Settings.vue';
|
||||
import Friends from '@/views/Friends.vue';
|
||||
import Groups from '@/views/Groups.vue';
|
||||
import GroupNew from '@/views/GroupNew.vue';
|
||||
import GroupDetail from '@/views/GroupDetail.vue';
|
||||
import Inventory from '@/views/Inventory.vue';
|
||||
import Search from '@/views/Search.vue';
|
||||
import InventoryDetail from '@/views/InventoryDetail.vue';
|
||||
import InventoryNew from '@/views/InventoryNew.vue';
|
||||
import InventoryEdit from '@/views/InventoryEdit.vue';
|
||||
import StorageLocation from '@/views/StorageLocation.vue';
|
||||
import StorageLocationDetail from '@/views/StorageLocationDetail.vue';
|
||||
import StorageLocationNew from '@/views/StorageLocationNew.vue';
|
||||
import StorageLocationEdit from '@/views/StorageLocationEdit.vue';
|
||||
import Admin from '@/views/Admin.vue';
|
||||
import Swatch from '@/views/Swatch.vue';
|
||||
import Files from '@/views/Files.vue';
|
||||
import Print from "@/views/Print.vue";
|
||||
import Scan from "@/views/Scan.vue";
|
||||
import Workflows from '@/views/Workflows.vue';
|
||||
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
||||
import ShortId from '@/views/ShortId.vue';
|
||||
import {
|
||||
encodeShortId, serializeShortId, decodeShortId, deserializeShortId,
|
||||
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
||||
} from '@/short-id';
|
||||
|
||||
import Account from '@/views/settings/Account.vue';
|
||||
import Password from '@/views/settings/Password.vue';
|
||||
import Privacy from '@/views/settings/Privacy.vue';
|
||||
import Email from '@/views/settings/Email.vue';
|
||||
import Notifications from '@/views/settings/Notifications.vue';
|
||||
import Data from '@/views/settings/Data.vue';
|
||||
import Preferences from '@/views/settings/Preferences.vue';
|
||||
|
||||
|
||||
const routes = [
|
||||
{path: '/', component: Index, meta: {requiresAuth: true}},
|
||||
{path: '/login', component: Login, meta: {requiresAuth: false}},
|
||||
{path: '/register', component: Register, meta: {requiresAuth: false}},
|
||||
]
|
||||
export function encodeHandleForUrl(handle) {
|
||||
return handle.replace(/#/g, "+");
|
||||
}
|
||||
|
||||
export function decodeHandleFromUrl(segment) {
|
||||
return segment.replace(/\+/g, "#");
|
||||
}
|
||||
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
export function ownerOverviewRoute(item) {
|
||||
return item.owner_group ? `/inventory/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
|
||||
}
|
||||
|
||||
export function ownerLocationOverviewRoute(location) {
|
||||
return location.owner_group ? `/storage-location/${encodeHandleForUrl(location.owner_group)}` : '/storage-location';
|
||||
}
|
||||
|
||||
const EXPANDED_ROUTE_BUILDERS = {
|
||||
item: ({owner_identity_id, item_local_id}) => {
|
||||
const handle = store.getters.identityHandleById[owner_identity_id];
|
||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
||||
},
|
||||
group_item: ({owner_group_id, item_local_id}) => {
|
||||
const handle = store.getters.groupHandleById[owner_group_id];
|
||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
||||
},
|
||||
group: ({group_id}) => {
|
||||
const handle = store.getters.groupHandleById[group_id];
|
||||
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
|
||||
},
|
||||
storage_location: ({owner_identity_id, storage_location_id}) => {
|
||||
const handle = store.getters.identityHandleById[owner_identity_id];
|
||||
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
|
||||
},
|
||||
group_storage_location: ({owner_group_id, storage_location_id}) => {
|
||||
const handle = store.getters.groupHandleById[owner_group_id];
|
||||
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null;
|
||||
},
|
||||
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
||||
};
|
||||
|
||||
// Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these.
|
||||
export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group', 'storage_location', 'group_storage_location']);
|
||||
|
||||
export function expandedRoute({kind, ...fields}) {
|
||||
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
||||
return buildRoute ? buildRoute(fields) : null;
|
||||
}
|
||||
|
||||
export function shortenedRoute(named) {
|
||||
return '/' + encodeShortId(serializeShortId(named));
|
||||
}
|
||||
|
||||
// Owned kinds only - matches handles-and-shortids.md's User-Qualified ID split (group/category/file have no owner id to hand a foreign backend).
|
||||
export const OWNED_KIND_FIELDS = {
|
||||
item: {ownerField: 'owner_identity_id', localField: 'item_local_id', isLocation: false},
|
||||
group_item: {ownerField: 'owner_group_id', localField: 'item_local_id', isLocation: false},
|
||||
storage_location: {ownerField: 'owner_identity_id', localField: 'storage_location_id', isLocation: true},
|
||||
group_storage_location: {ownerField: 'owner_group_id', localField: 'storage_location_id', isLocation: true},
|
||||
};
|
||||
|
||||
// Resolves a Domain-Qualified Short ID to a route, locally or via a network round-trip. See docs/implementation.md#domain-qualified-short-id-resolution.
|
||||
export async function domainQualifiedRoute(domain, ints) {
|
||||
const homeDomain = store.getters.isLoggedIn && store.state.user ? store.state.user.split('@')[1] : null;
|
||||
const deserialized = deserializeShortId(ints);
|
||||
if (domain === homeDomain) {
|
||||
return expandedRoute(deserialized) || undefined;
|
||||
}
|
||||
const owned = OWNED_KIND_FIELDS[deserialized.kind];
|
||||
if (!owned) {
|
||||
return undefined; // no owner id to resolve a foreign backend against yet (group/category/file/workflow)
|
||||
}
|
||||
const resolved = await store.dispatch('resolveShortId', {
|
||||
domain, kind: deserialized.kind,
|
||||
ownerId: deserialized[owned.ownerField], localId: deserialized[owned.localField],
|
||||
});
|
||||
if (!resolved || !resolved.handle) {
|
||||
return undefined;
|
||||
}
|
||||
return owned.isLocation
|
||||
? `/storage-locations/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`
|
||||
: `/inventory/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`;
|
||||
}
|
||||
|
||||
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
path: '/inventory/:owner?',
|
||||
component: Inventory,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/inventory/:handle/:id',
|
||||
component: InventoryDetail,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/inventory/:handle/:id/edit',
|
||||
component: InventoryEdit,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
// Label/short-link entry point; alias of /inventory/:handle/:id. See docs/implementation.md#item-short-link-redirect-route.
|
||||
path: '/i/:handle/:id',
|
||||
redirect: to => `/inventory/${to.params.handle}/${to.params.id}`
|
||||
}, {
|
||||
// beforeEnter, not redirect: falls through to ShortId.vue when the route can't resolve synchronously. See docs/implementation.md#beforeenter-guard-vs-redirect-for-short_id.
|
||||
path: '/:short_id',
|
||||
component: ShortId,
|
||||
props: true,
|
||||
beforeEnter: async to => {
|
||||
if (isDomainQualifiedShortId(to.params.short_id)) {
|
||||
const {domain, ints} = decodeDomainQualifiedShortId(to.params.short_id);
|
||||
return await domainQualifiedRoute(domain, ints);
|
||||
}
|
||||
return expandedRoute(deserializeShortId(decodeShortId(to.params.short_id)));
|
||||
}
|
||||
}, {
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
path: '/inventory/new/:group?',
|
||||
component: InventoryNew,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/friends',
|
||||
component: Friends,
|
||||
meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: '/groups',
|
||||
component: Groups,
|
||||
meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: '/groups/new',
|
||||
component: GroupNew,
|
||||
meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: '/groups/:handle',
|
||||
component: GroupDetail,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {path: '/files', component: Files, meta: {requiresAuth: true}
|
||||
}, {path: '/workflows',
|
||||
component: Workflows,
|
||||
meta: {requiresAuth: true},
|
||||
}, {path: '/workflows/:id/:step?',
|
||||
name: 'workflow-detail',
|
||||
component: WorkflowDetail,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {path: '/admin',
|
||||
component: Admin,
|
||||
meta: {requiresAuth: true}
|
||||
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {
|
||||
path: '/print',
|
||||
component: Print,
|
||||
meta: {requiresAuth: true},
|
||||
props: route => {
|
||||
const {kind, ...components} = route.query;
|
||||
// queryFields lets any base var (e.g. ?text=...) be set directly, without going through
|
||||
// a `kind` builder - see Print.vue's varValues.
|
||||
return {prefill: kind ? {kind, components} : null, queryFields: components};
|
||||
}
|
||||
}, {
|
||||
path: '/scan',
|
||||
component: Scan,
|
||||
meta: {requiresAuth: true},
|
||||
}, {
|
||||
path: '/search/:query',
|
||||
component: Search,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {path: '/login', component: Login, meta: {requiresAuth: false}}, {
|
||||
path: '/register',
|
||||
component: Register,
|
||||
meta: {requiresAuth: false}
|
||||
}, {path: '/pairing', component: Pairing, meta: {requiresAuth: false}}, {
|
||||
path: '/profile',
|
||||
component: Profile,
|
||||
meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: '/settings', component: Settings, meta: {requiresAuth: true}, children: [{
|
||||
path: '', name: 'account', component: Account, meta: {requiresAuth: true}
|
||||
},{
|
||||
path: 'password/', name: 'password', component: Password, meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: 'privacy/', name: 'privacy', component: Privacy, meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: 'email/', name: 'email', component: Email, meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: 'notifications/', name: 'notifications', component: Notifications, meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: 'data/', name: 'data', component: Data, meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: 'preferences/', name: 'preferences', component: Preferences, meta: {requiresAuth: true}
|
||||
}]
|
||||
}, {
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
path: '/storage-location/:owner?',
|
||||
component: StorageLocation,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/storage-locations/:handle/:id',
|
||||
component: StorageLocationDetail,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/storage-locations/:handle/:id/edit',
|
||||
component: StorageLocationEdit,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
// Label/short-link entry point for storage locations, the counterpart of /i/:handle/:id. See docs/implementation.md#item-short-link-redirect-route.
|
||||
path: '/s/:handle/:id',
|
||||
redirect: to => `/storage-locations/${to.params.handle}/${to.params.id}`
|
||||
}, {
|
||||
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
|
||||
path: '/storage-locations/new/:group?',
|
||||
component: StorageLocationNew,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
||||
|
||||
const router = createRouter({
|
||||
// 4. Provide the history implementation to use. We are using the hash history for simplicity here.
|
||||
history: createWebHistory(),
|
||||
linkActiveClass: "active",
|
||||
routes, // short for `routes: routes`
|
||||
history: createWebHistory(), linkActiveClass: "active", routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to/*, from*/) => {
|
||||
// instead of having to check every route record with
|
||||
// to.matched.some(record => record.meta.requiresAuth)
|
||||
if (to.meta.requiresAuth && false) {
|
||||
// this route requires auth, check if logged in
|
||||
// if not, redirect to login page.
|
||||
// if this route requires auth, check if logged in, if not, redirect to login page.
|
||||
if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
|
||||
console.log("Not logged in, redirecting to login page")
|
||||
return {
|
||||
path: '/login',
|
||||
// save the location we were at to come back later
|
||||
path: '/login', // save the location we were at to come back later
|
||||
query: {redirect: to.fullPath},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
.card-header {
|
||||
background-color: map-get($theme-colors, background-1);
|
||||
border-bottom: 0 solid transparent;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
|
|
|
|||
62
frontend/src/scss/_dropdown.scss
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
.dropdown-menu-header {
|
||||
padding: .75rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.dropdown-menu-footer {
|
||||
padding: .5rem;
|
||||
text-align: center;
|
||||
display: block;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.dropdown-menu-lg {
|
||||
min-width: 20rem;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.dropdown .list-group .list-group-item:first-child, .dropdown .list-group .list-group-item:last-child {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dropdown .list-group .list-group-item {
|
||||
border-width: 0 0 1px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.small, small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
.navbar .avatar {
|
||||
margin-top: -15px;
|
||||
margin-bottom: -15px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
margin-left: .255em;
|
||||
vertical-align: .255em;
|
||||
content: "";
|
||||
border-top: .3em solid;
|
||||
border-right: .3em solid transparent;
|
||||
border-bottom: 0;
|
||||
border-left: .3em solid transparent;
|
||||
}
|
||||
|
||||
.dropdown-toggle::after {
|
||||
border: solid;
|
||||
border-width: 0 2px 2px 0;
|
||||
display: inline-block;
|
||||
padding: 2px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
|
@ -9,6 +9,11 @@
|
|||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: #6c757d;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.form-control-lg {
|
||||
height: initial;
|
||||
min-height: calc(2.0875rem + 2px);
|
||||
|
|
@ -67,5 +72,9 @@
|
|||
align-items: center;
|
||||
padding: .25rem .7rem;
|
||||
background-color: map-get($theme-colors, background-2);
|
||||
#border-right-width: 0;
|
||||
}
|
||||
|
||||
.input-group-text:not(:last-child) {
|
||||
border-right-width: 0;
|
||||
}
|
||||
11
frontend/src/scss/_label-fonts.scss
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Normal-size (>=33px) label text font, so drawTextLeaf never falls back to the CSS generic
|
||||
// "sans-serif" keyword - see ../assets/fonts/label/LICENSE.md for source; label.js's fontTierFor
|
||||
// names this explicitly. The one @font-face left in this pair of files: every smaller tier renders
|
||||
// through ../pixel-font.js's own FreeType-wasm rasterizer instead, fetching its font bytes
|
||||
// directly rather than going through the browser's font system - see
|
||||
// docs/implementation.md#freetype-production-rendering.
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url("../assets/fonts/label/Inter-Regular.woff2") format("woff2");
|
||||
font-display: block;
|
||||
}
|
||||
3
frontend/src/scss/_tags.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.badge {
|
||||
color: #fff;
|
||||
}
|
||||
|
|
@ -90,6 +90,9 @@ $body-color: $gray-700;
|
|||
|
||||
@import "card";
|
||||
@import "forms";
|
||||
@import "tags";
|
||||
@import "dropdown";
|
||||
@import "label-fonts";
|
||||
|
||||
#root, body, html {
|
||||
height: 100%;
|
||||
|
|
@ -135,4 +138,41 @@ body {
|
|||
background-color: var(--bs-table-bg);
|
||||
background-image: linear-gradient(var(--bs-table-accent-bg), var(--bs-table-accent-bg));
|
||||
border-bottom-width: 1px !important;
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, xl)) {
|
||||
.main.expanded {
|
||||
.col-xl-12-ex {
|
||||
flex: 0 0 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.col-xl-9-ex {
|
||||
flex: 0 0 75% !important;
|
||||
max-width: 75% !important;
|
||||
}
|
||||
|
||||
.d-xl-block-ex {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, lg)) {
|
||||
.main.expanded {
|
||||
.col-lg-12-ex {
|
||||
flex: 0 0 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.col-lg-9-ex {
|
||||
flex: 0 0 75% !important;
|
||||
max-width: 75% !important;
|
||||
}
|
||||
|
||||
.d-lg-block-ex {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
238
frontend/src/short-id.js
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
const KIND_TAG_BITS = 2
|
||||
const CHUNK_BITS = 4
|
||||
|
||||
|
||||
const SCHEMAS = {
|
||||
'0-2': {
|
||||
name: 'item',
|
||||
fields: ['owner_identity_id', 'item_local_id'],
|
||||
interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id})
|
||||
},
|
||||
//0-1 left free for later use
|
||||
'1-2': {
|
||||
name: 'group_item',
|
||||
fields: ['owner_group_id', 'item_local_id'],
|
||||
interpret: ([owner_group_id, item_local_id]) => ({kind: 'group_item', owner_group_id, item_local_id})
|
||||
},
|
||||
'1-1': {
|
||||
name: 'group',
|
||||
fields: ['group_id'],
|
||||
interpret: ([group_id]) => ({kind: 'group', group_id})
|
||||
},
|
||||
'2-2': {
|
||||
name: 'storage_location',
|
||||
fields: ['owner_identity_id', 'storage_location_id'],
|
||||
interpret: ([owner_identity_id, storage_location_id]) =>
|
||||
({kind: 'storage_location', owner_identity_id, storage_location_id})
|
||||
},
|
||||
'2-1': {
|
||||
name: 'file',
|
||||
fields: ['file_id'],
|
||||
interpret: ([file_id]) => ({kind: 'file', file_id})
|
||||
},
|
||||
'3-2': {
|
||||
name: 'workflow',
|
||||
fields: ['owner_identity_id', 'workflow_id'],
|
||||
interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id})
|
||||
},
|
||||
'3-1': {
|
||||
name: 'category',
|
||||
fields: ['category_id'],
|
||||
interpret: ([category_id]) => ({kind: 'category', category_id})
|
||||
},
|
||||
'4-2': {
|
||||
name: 'group_storage_location',
|
||||
fields: ['owner_group_id', 'storage_location_id'],
|
||||
interpret: ([owner_group_id, storage_location_id]) =>
|
||||
({kind: 'group_storage_location', owner_group_id, storage_location_id})
|
||||
},
|
||||
}
|
||||
|
||||
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
||||
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1
|
||||
|
||||
class BitWriter {
|
||||
constructor() {
|
||||
this.bits = []
|
||||
}
|
||||
|
||||
writeBits(value, count) {
|
||||
value = BigInt(value)
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
this.bits.push(Number((value >> BigInt(i)) & 1n))
|
||||
}
|
||||
}
|
||||
|
||||
toBase64Url() {
|
||||
const padded = this.bits.slice()
|
||||
while (padded.length % 6 !== 0) {
|
||||
padded.push(0)
|
||||
}
|
||||
let token = ''
|
||||
for (let i = 0; i < padded.length; i += 6) {
|
||||
let n = 0
|
||||
for (let j = 0; j < 6; j++) {
|
||||
n = (n << 1) | padded[i + j]
|
||||
}
|
||||
token += BASE64URL_ALPHABET[n]
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
class BitReader {
|
||||
constructor(bits) {
|
||||
this.bits = bits
|
||||
this.pos = 0
|
||||
}
|
||||
|
||||
readBits(count) {
|
||||
let value = 0n
|
||||
for (let i = 0; i < count; i++) {
|
||||
value = (value << 1n) | BigInt(this.bits[this.pos] ?? 0)
|
||||
this.pos++
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
bitsLeft() {
|
||||
return this.bits.length - this.pos
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlToBits(text) {
|
||||
const bits = []
|
||||
for (const char of text) {
|
||||
const n = BASE64URL_ALPHABET.indexOf(char)
|
||||
if (n === -1) {
|
||||
throw new Error(`invalid short id: '${char}' is not a URL-safe base64 character`)
|
||||
}
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
bits.push((n >> i) & 1)
|
||||
}
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
function writeChunkedInt(writer, value, chunkBits) {
|
||||
value = BigInt(value)
|
||||
if (value < 0n) {
|
||||
throw new Error('short id values must be non-negative integers')
|
||||
}
|
||||
const mask = (1n << BigInt(chunkBits)) - 1n
|
||||
const chunks = []
|
||||
let remaining = value
|
||||
do {
|
||||
chunks.unshift(remaining & mask)
|
||||
remaining >>= BigInt(chunkBits)
|
||||
} while (remaining > 0n)
|
||||
chunks.forEach((chunk, i) => {
|
||||
writer.writeBits(i === chunks.length - 1 ? 0 : 1, 1)
|
||||
writer.writeBits(chunk, chunkBits)
|
||||
})
|
||||
}
|
||||
|
||||
function readChunkedInt(reader, chunkBits) {
|
||||
let value = 0n
|
||||
for (; ;) {
|
||||
const more = reader.readBits(1)
|
||||
const data = reader.readBits(chunkBits)
|
||||
value = (value << BigInt(chunkBits)) | data
|
||||
if (more === 0n) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schemaForKindAndArity(kindIndex, arity) {
|
||||
const schema = SCHEMAS[`${kindIndex}-${arity}`]
|
||||
if (!schema) {
|
||||
throw new Error(`unknown short id schema for kind ${kindIndex} with ${arity} field value(s)`)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
export function encodeShortId(ints) {
|
||||
if (!Array.isArray(ints) || ints.length === 0) {
|
||||
throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]')
|
||||
}
|
||||
const [kindIndex, ...fieldValues] = ints
|
||||
const schema = schemaForKindAndArity(kindIndex, fieldValues.length)
|
||||
if (fieldValues[fieldValues.length - 1] === 0) {
|
||||
throw new Error(
|
||||
`short id kind index ${kindIndex} ('${schema.name}'): the last field's value must not be 0 - `
|
||||
+ `reserved so decodeShortId can tell a real trailing field apart from zero-padding without `
|
||||
+ `knowing the kind's arity up front (see docs/handles-and-shortids.md)`
|
||||
)
|
||||
}
|
||||
const writer = new BitWriter()
|
||||
if (kindIndex < DIRECT_KIND_COUNT) {
|
||||
writer.writeBits(kindIndex, KIND_TAG_BITS)
|
||||
} else {
|
||||
writer.writeBits(DIRECT_KIND_COUNT, KIND_TAG_BITS)
|
||||
writeChunkedInt(writer, kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS)
|
||||
}
|
||||
for (const value of fieldValues) {
|
||||
writeChunkedInt(writer, value, CHUNK_BITS)
|
||||
}
|
||||
return '~' + writer.toBase64Url()
|
||||
}
|
||||
|
||||
export function decodeShortId(token) {
|
||||
if (typeof token !== 'string' || !token.startsWith('~')) {
|
||||
throw new Error("short id must start with '~'")
|
||||
}
|
||||
const reader = new BitReader(base64UrlToBits(token.slice(1)))
|
||||
let kindIndex = Number(reader.readBits(KIND_TAG_BITS))
|
||||
if (kindIndex === DIRECT_KIND_COUNT) {
|
||||
kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS))
|
||||
}
|
||||
const fieldValues = []
|
||||
while (reader.bitsLeft() >= CHUNK_BITS + 1) {
|
||||
const value = readChunkedInt(reader, CHUNK_BITS)
|
||||
if (value === 0n && reader.bitsLeft() === 0) {
|
||||
break // a trailing all-zero chunk with nothing after it is padding, not a real field
|
||||
}
|
||||
fieldValues.push(Number(value))
|
||||
}
|
||||
return [kindIndex, ...fieldValues]
|
||||
}
|
||||
|
||||
export function deserializeShortId(ints) {
|
||||
const [kindIndex, ...fieldValues] = ints
|
||||
const schema = schemaForKindAndArity(kindIndex, fieldValues.length)
|
||||
return schema.interpret(fieldValues)
|
||||
}
|
||||
|
||||
export function encodeDomainQualifiedShortId(domain, ints) {
|
||||
return domain + ':' + encodeShortId(ints)
|
||||
}
|
||||
|
||||
export function decodeDomainQualifiedShortId(text) {
|
||||
const i = text.indexOf(':~')
|
||||
if (i === -1) {
|
||||
throw new Error("not a domain-qualified short id: expected '<domain>:~<token>'")
|
||||
}
|
||||
return {domain: text.slice(0, i), token: text.slice(i + 1), ints: decodeShortId(text.slice(i + 1))}
|
||||
}
|
||||
|
||||
export function isDomainQualifiedShortId(text) {
|
||||
return typeof text === 'string' && /^[^\s~]+:~/.test(text)
|
||||
}
|
||||
|
||||
export function serializeShortId({kind, ...fieldValues}) {
|
||||
const entry = Object.entries(SCHEMAS).find(([, s]) => s.name === kind)
|
||||
if (!entry) {
|
||||
throw new Error(`unknown short id kind: ${kind}`)
|
||||
}
|
||||
const [key, schema] = entry
|
||||
const kindIndex = Number(key.split('-')[0])
|
||||
const values = schema.fields.map(name => {
|
||||
const value = fieldValues[name]
|
||||
if (value === undefined) {
|
||||
throw new Error(`missing field '${name}' for short id kind '${kind}'`)
|
||||
}
|
||||
return value
|
||||
})
|
||||
return [kindIndex, ...values]
|
||||
}
|
||||
|
|
@ -1,28 +1,151 @@
|
|||
import {createStore} from 'vuex';
|
||||
import router from '@/router';
|
||||
import router, {encodeHandleForUrl} from '@/router';
|
||||
import FallBackResolver from "@/dns";
|
||||
import NeighborsCache from "@/neigbors";
|
||||
import {createNullAuth, createSignAuth, createTokenAuth, ServerSet, ServerSetUnion} from "@/federation";
|
||||
import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
|
||||
import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.js";
|
||||
import {defaultPreferenceDefinitions, loadDevicePreferences, saveDevicePreferences, resolvePreference} from "@/preferences.js";
|
||||
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
|
||||
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
|
||||
|
||||
function splitGroupHandle(handle) {
|
||||
const [name, domain] = handle.slice(1).split('@')
|
||||
return {name, domain}
|
||||
}
|
||||
|
||||
export default createStore({
|
||||
state: {
|
||||
local_loaded: false,
|
||||
last_load: {},
|
||||
user: null,
|
||||
user_profile: null,
|
||||
token: null,
|
||||
keypair: null,
|
||||
remember: false,
|
||||
friends: [],
|
||||
friendProfiles: {},
|
||||
groups: [],
|
||||
groupInvites: [],
|
||||
groupMemberships: [],
|
||||
idmap: {identities: [], groups: []},
|
||||
idmapLoaded: false,
|
||||
item_map: {},
|
||||
location_map: {},
|
||||
home_servers: null,
|
||||
all_friends_servers: null,
|
||||
messages: [],
|
||||
resolver: new FallBackResolver(),
|
||||
unreachable_neighbors: new NeighborsCache(),
|
||||
tags: [],
|
||||
properties: [],
|
||||
files: [],
|
||||
categories: [],
|
||||
availability_policies: [],
|
||||
visibility_policies: [],
|
||||
domains: [],
|
||||
storage_locations: [],
|
||||
active_workflows: [],
|
||||
// Static schema and device-local values are available synchronously - see
|
||||
// docs/implementation.md#preferences - unlike accountPreferences, which needs a network
|
||||
// round trip and is gated by preferencesLoaded.
|
||||
preferenceDefinitions: defaultPreferenceDefinitions,
|
||||
accountPreferences: {},
|
||||
devicePreferences: loadDevicePreferences(),
|
||||
preferencesLoaded: false,
|
||||
},
|
||||
mutations: {
|
||||
setInventoryItems(state, {url, items}) {
|
||||
state.item_map[url] = items;
|
||||
},
|
||||
setLocationsForKey(state, {url, locations}) {
|
||||
state.location_map[url] = locations;
|
||||
},
|
||||
setFriends(state, friends) {
|
||||
state.friends = friends;
|
||||
},
|
||||
setFriendProfile(state, {username, profile}) {
|
||||
state.friendProfiles = {...state.friendProfiles, [username]: profile};
|
||||
},
|
||||
setFriendProfiles(state, profiles) {
|
||||
state.friendProfiles = profiles;
|
||||
},
|
||||
setGroups(state, groups) {
|
||||
state.groups = groups;
|
||||
},
|
||||
setGroupInvites(state, invites) {
|
||||
state.groupInvites = invites;
|
||||
},
|
||||
setGroupMemberships(state, memberships) {
|
||||
state.groupMemberships = memberships;
|
||||
},
|
||||
setIdMap(state, idmap) {
|
||||
state.idmap = idmap;
|
||||
state.idmapLoaded = true;
|
||||
},
|
||||
setHomeServers(state, home_servers) {
|
||||
state.home_servers = home_servers;
|
||||
},
|
||||
setAllFriendsServers(state, servers) {
|
||||
state.all_friends_servers = servers;
|
||||
},
|
||||
setTags(state, tags) {
|
||||
state.tags = tags;
|
||||
},
|
||||
setProperties(state, properties) {
|
||||
state.properties = properties;
|
||||
},
|
||||
setCategories(state, categories) {
|
||||
state.categories = categories;
|
||||
},
|
||||
setAvailabilityPolicies(state, availability_policies) {
|
||||
state.availability_policies = availability_policies;
|
||||
},
|
||||
setVisibilityPolicies(state, visibility_policies) {
|
||||
state.visibility_policies = visibility_policies;
|
||||
},
|
||||
setDomains(state, domains) {
|
||||
state.domains = domains;
|
||||
},
|
||||
setStorageLocations(state, storage_locations) {
|
||||
state.storage_locations = storage_locations;
|
||||
},
|
||||
setFiles(state, files) {
|
||||
state.files = files;
|
||||
},
|
||||
setActiveWorkflows(state, workflows) {
|
||||
state.active_workflows = workflows;
|
||||
},
|
||||
setAccountPreferences(state, preferences) {
|
||||
state.accountPreferences = preferences;
|
||||
},
|
||||
setAccountPreference(state, {key, value}) {
|
||||
state.accountPreferences = {...state.accountPreferences, [key]: value};
|
||||
},
|
||||
setDevicePreference(state, {key, value}) {
|
||||
state.devicePreferences = {...state.devicePreferences, [key]: value};
|
||||
},
|
||||
deleteAccountPreference(state, key) {
|
||||
const prefs = {...state.accountPreferences};
|
||||
delete prefs[key];
|
||||
state.accountPreferences = prefs;
|
||||
},
|
||||
deleteDevicePreference(state, key) {
|
||||
const prefs = {...state.devicePreferences};
|
||||
delete prefs[key];
|
||||
state.devicePreferences = prefs;
|
||||
},
|
||||
setPreferencesLoaded(state, loaded) {
|
||||
state.preferencesLoaded = loaded;
|
||||
},
|
||||
setUser(state, user) {
|
||||
state.user = user;
|
||||
if (state.remember)
|
||||
localStorage.setItem('user', user);
|
||||
},
|
||||
setUserProfile(state, profile) {
|
||||
state.user_profile = profile;
|
||||
},
|
||||
setToken(state, token) {
|
||||
state.token = token;
|
||||
if (state.remember)
|
||||
|
|
@ -42,13 +165,29 @@ export default createStore({
|
|||
}
|
||||
localStorage.setItem('remember', remember);
|
||||
},
|
||||
setHomeServers(state, home_servers) {
|
||||
state.home_servers = home_servers;
|
||||
setUserFromIdentityRecord(state, {user, keypair}) {
|
||||
try {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('keypair');
|
||||
state.user = user;
|
||||
state.keypair = keypair;
|
||||
if (state.remember) {
|
||||
localStorage.setItem('user', user);
|
||||
localStorage.setItem('keypair', key)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Invalid identity record')
|
||||
}
|
||||
},
|
||||
logout(state) {
|
||||
state.user = null;
|
||||
state.user_profile = null;
|
||||
state.token = null;
|
||||
state.keypair = null;
|
||||
state.accountPreferences = {};
|
||||
state.preferencesLoaded = false;
|
||||
// Note: devicePreferences/preferenceDefinitions are NOT cleared on logout (device-specific / static)
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('keypair');
|
||||
|
|
@ -67,11 +206,42 @@ export default createStore({
|
|||
if (keypair) {
|
||||
this.commit('setKey', keypair)
|
||||
}
|
||||
router.push('/');
|
||||
}
|
||||
state.cache_loaded = true;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async loadUserPreferences({state, commit, dispatch, getters}) {
|
||||
if (state.preferencesLoaded) return
|
||||
let accountPreferences = {}
|
||||
try {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
accountPreferences = await servers.get(getters.signAuth, '/auth/self/preferences/') || {}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user preferences:', error)
|
||||
}
|
||||
commit('setAccountPreferences', accountPreferences)
|
||||
commit('setPreferencesLoaded', true)
|
||||
},
|
||||
async setAccountPreference({state, commit, dispatch, getters}, {key, value}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.put(getters.signAuth, '/auth/self/preferences/', {[key]: value})
|
||||
commit('setAccountPreference', {key, value})
|
||||
},
|
||||
async resetAccountPreference({state, commit, dispatch, getters}, key) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/auth/self/preferences/' + key + '/')
|
||||
commit('deleteAccountPreference', key)
|
||||
},
|
||||
async setDevicePreference({state, commit}, {key, value}) {
|
||||
commit('setDevicePreference', {key, value})
|
||||
saveDevicePreferences(state.devicePreferences)
|
||||
},
|
||||
async resetDevicePreference({state, commit}, key) {
|
||||
commit('deleteDevicePreference', key)
|
||||
saveDevicePreferences(state.devicePreferences)
|
||||
},
|
||||
async login({commit, dispatch, state, getters}, {username, password, remember}) {
|
||||
commit('setRemember', remember);
|
||||
const data = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
|
||||
|
|
@ -82,28 +252,571 @@ export default createStore({
|
|||
commit('setKey', data.key);
|
||||
const s = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
|
||||
commit('setHomeServers', s)
|
||||
await dispatch('fetchUserProfile', {force: true})
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async fetchUserProfile({state, commit, dispatch, getters}, {force = false} = {}) {
|
||||
if (!force && state.user_profile && state.last_load.user_profile > Date.now() - 1000 * 60) {
|
||||
return state.user_profile
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/auth/user/')
|
||||
commit('setUserProfile', data)
|
||||
state.last_load.user_profile = Date.now()
|
||||
return data
|
||||
},
|
||||
async updateUserProfilePicture({state, commit, dispatch, getters}, {file = null} = {}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const payload = file ? {profile_picture: {data: file.data, mime_type: file.mime_type}} : {profile_picture: null}
|
||||
const data = await servers.patch(getters.signAuth, '/auth/user/', payload)
|
||||
commit('setUserProfile', data)
|
||||
state.last_load.user_profile = Date.now()
|
||||
if (data.profile_picture) {
|
||||
state.last_load.files = 0
|
||||
}
|
||||
return data
|
||||
},
|
||||
async lookupServer({state}, {username}) {
|
||||
const domain = username.split('@')[1]
|
||||
const request = '_toolshed-server._tcp.' + domain + '.'
|
||||
return await state.resolver.query(request, 'SRV').then(
|
||||
(result) => result.map(
|
||||
(answer) => answer.target + ':' + answer.port))
|
||||
// Must match the browser's real Host header (federation.js signs/fetches
|
||||
// "https://"+server+target); browser omits :443 for default HTTPS, so
|
||||
// keeping it here would break signature checks on the receiving end.
|
||||
(answer) => answer.port === 443 ? answer.target : answer.target + ':' + answer.port))
|
||||
},
|
||||
async getHomeServers({state, dispatch, commit}) {
|
||||
async getHomeServers({state, dispatch, commit, getters}) {
|
||||
if (state.home_servers)
|
||||
return state.home_servers
|
||||
// Reading isLoggedIn first forces its lazy hydration of state.user from localStorage,
|
||||
// needed here since routes without requiresAuth (e.g. short-id redirect) skip that
|
||||
// check; fail clearly if still not logged in rather than crashing on username.split.
|
||||
if (!getters.isLoggedIn) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
const promise = dispatch('lookupServer', {username: state.user}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
|
||||
commit('setHomeServers', promise)
|
||||
return promise
|
||||
},
|
||||
async getAllKnownServers({state, dispatch, commit}) {
|
||||
const friends = await dispatch('fetchFriends')
|
||||
if (state.all_friends_servers)
|
||||
return state.all_friends_servers
|
||||
const promise = (async () => {
|
||||
const servers = new ServerSetUnion([])
|
||||
const home = await dispatch('getHomeServers')
|
||||
servers.add(home)
|
||||
for (const friend of friends) {
|
||||
try {
|
||||
const s = await dispatch('lookupServer', {username: friend.handle})
|
||||
servers.add(new ServerSet(s, state.unreachable_neighbors))
|
||||
} catch (e) {
|
||||
// Skip an unresolvable/unreachable friend rather than aborting the whole lookup.
|
||||
console.error('could not resolve server for friend', friend.handle, e)
|
||||
}
|
||||
}
|
||||
return servers
|
||||
})().catch(e => {
|
||||
// Don't cache a permanently-rejected promise - allow a retry next time.
|
||||
state.all_friends_servers = null
|
||||
throw e
|
||||
})
|
||||
commit('setAllFriendsServers', promise)
|
||||
return promise
|
||||
},
|
||||
async getFriendServers({state, dispatch, commit}, {username}) {
|
||||
return dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
|
||||
},
|
||||
async fetchBackendVersion({dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.nullAuth, '/api/version/')
|
||||
},
|
||||
async fetchInventoryItems({state, commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const items = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + state.user + '/')
|
||||
items.map(item => item.files.map(file => file.owner = item.owner))
|
||||
commit('setInventoryItems', {url: '/', items})
|
||||
return items
|
||||
},
|
||||
async createInventoryItem({state, dispatch, getters}, item) {
|
||||
const servers = item.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const owner = item.owner_group ? encodeHandleForUrl(item.owner_group) : state.user
|
||||
const data = {availability_policy: 'private', visibility_policy: 'private', ...item}
|
||||
delete data.owner_group
|
||||
const reply = await servers.post(getters.signAuth, '/api/v1/inventory_items/' + owner + '/', data)
|
||||
state.last_load.files = 0
|
||||
return reply
|
||||
},
|
||||
async updateInventoryItem({state, dispatch, getters}, item) {
|
||||
const servers = item.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const data = {availability_policy: 'friends', visibility_policy: 'friends', ...item}
|
||||
data.files = data.files.map(file => file.id)
|
||||
// Path is scoped by the owner's handle, not the item's own id domain - see docs/implementation.md#owner-handle-scoped-routes.
|
||||
const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
|
||||
return await servers.patch(getters.signAuth, path, data)
|
||||
},
|
||||
async deleteInventoryItem({state, dispatch, getters}, item) {
|
||||
const servers = item.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
|
||||
// No self-refetch here, same as createInventoryItem/updateInventoryItem - callers
|
||||
// refetch whichever owner-scoped list (own/group/friend) is actually on screen.
|
||||
return await servers.delete(getters.signAuth, path)
|
||||
},
|
||||
async fetchSearchResults({state, commit, dispatch, getters}, {query}) {
|
||||
const servers = await dispatch('getAllKnownServers')
|
||||
const items = await servers.get(getters.signAuth, '/api/v1/search/?query=' + query)
|
||||
items.map(item => item.files.map(file => file.owner = item.owner))
|
||||
commit('setInventoryItems', {url: '/search/' + query, items})
|
||||
return items
|
||||
},
|
||||
async fetchFriends({commit, dispatch, getters, state}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/friends/')
|
||||
commit('setFriends', data)
|
||||
return data
|
||||
},
|
||||
async fetchFriendProfile({commit, dispatch, getters}, {username}) {
|
||||
try {
|
||||
const servers = await dispatch('getFriendServers', {username});
|
||||
// username here is already a full handle (username@domain), see FriendSerializer
|
||||
const profile = await servers.get(getters.signAuth, '/auth/user/' + username + '/');
|
||||
if (profile) {
|
||||
commit('setFriendProfile', {username, profile});
|
||||
}
|
||||
return profile;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch profile for ${username}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async fetchForeignItem({dispatch, getters}, {owner, id}) {
|
||||
try {
|
||||
const servers = await dispatch('getFriendServers', {username: owner});
|
||||
// owner is a full handle, looked up by owner not requester. See docs/implementation.md#owner-handle-scoped-routes.
|
||||
const item = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + owner + '/' + id + '/');
|
||||
if (item && item.files) {
|
||||
item.files.forEach(file => file.owner = item.owner)
|
||||
}
|
||||
return item;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch item ${id} for ${owner}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async fetchItemByHandle({dispatch, getters}, {handle, id}) {
|
||||
if (handle.startsWith('#')) {
|
||||
try {
|
||||
const items = await dispatch('fetchGroupInventoryItems', {groupHandle: handle})
|
||||
return items.find(item => item.id === parseInt(id)) || null
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch group item ${id} for ${handle}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return dispatch('fetchForeignItem', {owner: handle, id})
|
||||
},
|
||||
async fetchFriendRequests({state, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.signAuth, '/api/v1/friendrequests/')
|
||||
},
|
||||
async requestFriend({state, dispatch, getters}, {username}) {
|
||||
if (username in state.friends) {
|
||||
return true;
|
||||
}
|
||||
const home_servers = await dispatch('getHomeServers')
|
||||
const home_reply = await home_servers.post(getters.signAuth, '/api/v1/friendrequests/', {
|
||||
befriender: state.user,
|
||||
befriendee: username
|
||||
})
|
||||
if (home_reply.status !== 'pending' || !home_reply.secret)
|
||||
return false;
|
||||
|
||||
const befriendee_servers = await dispatch('getFriendServers', {username})
|
||||
const ext_reply = await befriendee_servers.post(getters.signAuth, '/api/v1/friendrequests/', {
|
||||
befriender: state.user,
|
||||
befriendee: username,
|
||||
befriender_key: nacl.to_hex(state.keypair.signPk),
|
||||
secret: home_reply.secret
|
||||
})
|
||||
return true;
|
||||
},
|
||||
async acceptFriend({state, dispatch, getters}, {id, secret, befriender}) {
|
||||
const home_servers = await dispatch('getHomeServers')
|
||||
const home_reply = await home_servers.post(getters.signAuth, '/api/v1/friends/', {
|
||||
friend_request_id: id, secret: secret
|
||||
})
|
||||
const ext_servers = await dispatch('getFriendServers', {username: befriender})
|
||||
const ext_reply = await ext_servers.post(getters.signAuth, '/api/v1/friendrequests/', {
|
||||
befriender: state.user,
|
||||
befriendee: befriender,
|
||||
befriender_key: nacl.to_hex(state.keypair.signPk),
|
||||
secret: secret
|
||||
})
|
||||
return true
|
||||
},
|
||||
async declineFriend({state, dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.delete(getters.signAuth, '/api/v1/friendrequests/' + id + '/')
|
||||
},
|
||||
async dropFriend({state, dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.delete(getters.signAuth, '/api/v1/friends/' + id + '/')
|
||||
},
|
||||
async fetchGroups({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/groups/')
|
||||
commit('setGroups', data)
|
||||
return data
|
||||
},
|
||||
async fetchIdMap({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const idmap = await servers.get(getters.signAuth, '/api/v1/idmap/')
|
||||
commit('setIdMap', idmap)
|
||||
return idmap
|
||||
},
|
||||
// See docs/implementation.md#domain-qualified-short-id-resolution.
|
||||
async resolveShortId({dispatch, getters}, {domain, kind, ownerId, localId}) {
|
||||
try {
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
return await servers.get(getters.signAuth, `/api/v1/resolve_short_id/${kind}/${ownerId}/${localId}/`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to resolve short id (${kind}, ${ownerId}, ${localId}) on ${domain}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// handle is "#name@domain".
|
||||
async fetchGroup({dispatch, getters}, {handle}) {
|
||||
const {domain} = splitGroupHandle(handle)
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
return await servers.get(getters.signAuth, `/api/v1/groups/${handle.slice(1)}/`)
|
||||
},
|
||||
async createGroup({dispatch, getters}, {name}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.post(getters.signAuth, '/api/v1/groups/', {name})
|
||||
},
|
||||
async removeGroupMember({dispatch, getters}, {groupHandle, identityId}) {
|
||||
const {domain} = splitGroupHandle(groupHandle)
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
return await servers.delete(
|
||||
getters.signAuth, '/api/v1/groups/' + groupHandle.slice(1) + '/members/' + identityId + '/')
|
||||
},
|
||||
async fetchGroupInvites({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/groupinvites/')
|
||||
commit('setGroupInvites', data)
|
||||
return data
|
||||
},
|
||||
async fetchGroupMemberships({commit, dispatch, getters}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/groupmemberships/')
|
||||
commit('setGroupMemberships', data)
|
||||
return data
|
||||
},
|
||||
async inviteToGroup({state, dispatch, getters}, {groupHandle, invitee}) {
|
||||
const {domain} = splitGroupHandle(groupHandle)
|
||||
const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
const group_reply = await group_servers.post(
|
||||
getters.signAuth, '/api/v1/groups/' + groupHandle.slice(1) + '/invites/', {invitee})
|
||||
if (!group_reply.secret) {
|
||||
return false
|
||||
}
|
||||
const invitee_servers = await dispatch('getFriendServers', {username: invitee})
|
||||
await invitee_servers.post(getters.signAuth, '/api/v1/groupinvites/', {
|
||||
group: groupHandle,
|
||||
inviter: state.user,
|
||||
inviter_key: nacl.to_hex(state.keypair.signPk),
|
||||
invitee,
|
||||
secret: group_reply.secret
|
||||
})
|
||||
return true
|
||||
},
|
||||
async acceptGroupInvite({state, dispatch, getters}, invite) {
|
||||
const group_domain = invite.group.split('@')[1]
|
||||
const group_servers = await dispatch('getFriendServers', {username: 'x@' + group_domain})
|
||||
await group_servers.post(getters.signAuth, '/api/v1/group_invites/accept/', {
|
||||
group: invite.group,
|
||||
invitee: state.user,
|
||||
invitee_key: nacl.to_hex(state.keypair.signPk),
|
||||
secret: invite.secret
|
||||
})
|
||||
const home_servers = await dispatch('getHomeServers')
|
||||
await home_servers.post(getters.signAuth, '/api/v1/groupinvites/' + invite.id + '/accept/', {})
|
||||
return true
|
||||
},
|
||||
async declineGroupInvite({dispatch, getters}, invite) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.delete(getters.signAuth, '/api/v1/groupinvites/' + invite.id + '/')
|
||||
},
|
||||
async fetchGroupInventoryItems({commit, dispatch, getters}, {groupHandle}) {
|
||||
const {domain} = splitGroupHandle(groupHandle)
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
const items = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + encodeHandleForUrl(groupHandle) + '/')
|
||||
commit('setInventoryItems', {url: '/' + groupHandle, items})
|
||||
return items
|
||||
},
|
||||
async fetchFriendInventoryItems({commit, dispatch, getters}, {friendHandle}) {
|
||||
const servers = await dispatch('getFriendServers', {username: friendHandle})
|
||||
const items = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + encodeHandleForUrl(friendHandle) + '/')
|
||||
items.map(item => item.files.map(file => file.owner = item.owner))
|
||||
commit('setInventoryItems', {url: '/' + friendHandle, items})
|
||||
return items
|
||||
},
|
||||
async fetchFiles({state, commit, dispatch, getters}) {
|
||||
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.files
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/files/')
|
||||
data.map(file => file.owner = state.user)
|
||||
commit('setFiles', data)
|
||||
state.last_load.files = Date.now()
|
||||
return data
|
||||
},
|
||||
async fetchFilesByItem({state, commit, dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
return await servers.get(getters.signAuth, '/api/v1/item_files/' + id + '/')
|
||||
},
|
||||
async pushFile({state, dispatch, getters}, {item_id, file}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/item_files/' + item_id + '/', file)
|
||||
if (data.name) {
|
||||
data.owner = state.user
|
||||
state.files.push(data)
|
||||
return data
|
||||
}
|
||||
},
|
||||
async deleteFile({state, dispatch, getters}, {id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/v1/files/' + id + '/')
|
||||
state.files = state.files.filter(file => file.id !== id)
|
||||
},
|
||||
async deleteItemFile({state, dispatch, getters}, {item_id, file_id}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/v1/item_files/' + item_id + '/' + file_id + '/')
|
||||
state.files = state.files.filter(file => file.id !== file_id)
|
||||
},
|
||||
async stageFile({state, dispatch, getters}, {lifetime_id, file}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/staged_files/' + lifetime_id + '/', file)
|
||||
if (data.hash) {
|
||||
return data.hash
|
||||
}
|
||||
},
|
||||
async unstageFile({state, dispatch, getters}, {lifetime_id, file_hash}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/v1/staged_files/' + lifetime_id + '/' + file_hash + '/')
|
||||
},
|
||||
async commitStagedFile({state, dispatch, getters}, {item_id, file_hash}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/item_files/' + item_id + '/', {file_hash})
|
||||
if (data.name) {
|
||||
data.owner = state.user
|
||||
state.files.push(data)
|
||||
return data
|
||||
}
|
||||
},
|
||||
async fetchTags({state, commit, dispatch, getters}) {
|
||||
if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.tags
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/tags/')
|
||||
commit('setTags', data)
|
||||
state.last_load.tags = Date.now()
|
||||
return data
|
||||
},
|
||||
async fetchProperties({state, commit, dispatch, getters}) {
|
||||
if (state.last_load.properties > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.properties
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/properties/')
|
||||
commit('setProperties', data)
|
||||
state.last_load.properties = Date.now()
|
||||
return data
|
||||
},
|
||||
async fetchCategories({state, commit, dispatch, getters}) {
|
||||
if (state.last_load.categories > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.categories
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/categories/')
|
||||
commit('setCategories', data)
|
||||
state.last_load.categories = Date.now()
|
||||
return data
|
||||
},
|
||||
async fetchAvailabilityPolicies({state, commit, dispatch, getters}) {
|
||||
if (state.last_load.availability_policies > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.availability_policies
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/availability_policies/')
|
||||
commit('setAvailabilityPolicies', data.map(policy => ({slug: policy[0], text: policy[1]})))
|
||||
state.last_load.availability_policies = Date.now()
|
||||
return data
|
||||
},
|
||||
async fetchVisibilityPolicies({state, commit, dispatch, getters}) {
|
||||
if (state.last_load.visibility_policies > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.visibility_policies
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/visibility_policies/')
|
||||
commit('setVisibilityPolicies', data.map(policy => ({slug: policy[0], text: policy[1]})))
|
||||
state.last_load.visibility_policies = Date.now()
|
||||
return data
|
||||
},
|
||||
async fetchStorageLocations({state, commit, dispatch, getters}) {
|
||||
if (state.last_load.storage_locations > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.storage_locations
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/storage_locations/' + state.user + '/')
|
||||
commit('setStorageLocations', data)
|
||||
state.last_load.storage_locations = Date.now()
|
||||
return data
|
||||
},
|
||||
async fetchGroupStorageLocations({commit, dispatch, getters}, {groupHandle}) {
|
||||
const {domain} = splitGroupHandle(groupHandle)
|
||||
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||
const locations = await servers.get(
|
||||
getters.signAuth, '/api/v1/storage_locations/' + encodeHandleForUrl(groupHandle) + '/')
|
||||
commit('setLocationsForKey', {url: '/' + groupHandle, locations})
|
||||
return locations
|
||||
},
|
||||
async fetchStorageLocationByHandle({dispatch, getters}, {handle, id}) {
|
||||
if (handle.startsWith('#')) {
|
||||
try {
|
||||
const locations = await dispatch('fetchGroupStorageLocations', {groupHandle: handle})
|
||||
return locations.find(location => location.id === parseInt(id)) || null
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch group storage location ${id} for ${handle}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const locations = await dispatch('fetchStorageLocations')
|
||||
return locations.find(location => location.id === parseInt(id)) || null
|
||||
},
|
||||
async deleteStorageLocation({state, dispatch, getters}, location) {
|
||||
const servers = location.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
// Path is scoped by the owner's handle, not the location's own id domain - see docs/implementation.md#owner-handle-scoped-routes.
|
||||
const path = '/api/v1/storage_locations/' + encodeHandleForUrl(location.owner_group || location.owner) + '/' + location.id + '/'
|
||||
const ret = await servers.delete(getters.signAuth, path)
|
||||
state.last_load.storage_locations = 0
|
||||
dispatch('fetchStorageLocations')
|
||||
return ret
|
||||
},
|
||||
// location.owner_group is the group's own "#name@domain" handle - see
|
||||
// createInventoryItem's own comment.
|
||||
async createStorageLocation({state, dispatch, getters}, location) {
|
||||
const servers = location.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const owner = location.owner_group ? encodeHandleForUrl(location.owner_group) : state.user
|
||||
const data = {...location}
|
||||
delete data.owner_group
|
||||
if (data.parent === '') data.parent = null
|
||||
const reply = await servers.post(getters.signAuth, '/api/v1/storage_locations/' + owner + '/', data)
|
||||
state.last_load.storage_locations = 0
|
||||
return reply
|
||||
},
|
||||
async updateStorageLocation({state, dispatch, getters}, location) {
|
||||
const servers = location.owner_group
|
||||
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(location.owner_group).domain})
|
||||
: await dispatch('getHomeServers')
|
||||
const data = {...location}
|
||||
if (data.parent === '') data.parent = null
|
||||
const path = '/api/v1/storage_locations/' + encodeHandleForUrl(location.owner_group || location.owner) + '/' + location.id + '/'
|
||||
const reply = await servers.patch(getters.signAuth, path, data)
|
||||
state.last_load.storage_locations = 0
|
||||
dispatch('fetchStorageLocations')
|
||||
return reply
|
||||
},
|
||||
async fetchInfo({state, commit, dispatch, getters}) {
|
||||
const last_load_info = Math.min(
|
||||
state.last_load.tags,
|
||||
state.last_load.properties,
|
||||
state.last_load.categories,
|
||||
state.last_load.availability_policies,
|
||||
state.last_load.visibility_policies)
|
||||
if (last_load_info > Date.now() - 1000 * 60 * 60 * 24) {
|
||||
return state.info
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/info/')
|
||||
commit('setTags', data.tags)
|
||||
commit('setProperties', data.properties)
|
||||
commit('setCategories', data.categories)
|
||||
commit('setAvailabilityPolicies', data.availability_policies.map(policy => ({
|
||||
slug: policy[0],
|
||||
text: policy[1]
|
||||
})))
|
||||
commit('setVisibilityPolicies', data.visibility_policies.map(policy => ({
|
||||
slug: policy[0],
|
||||
text: policy[1]
|
||||
})))
|
||||
commit('setDomains', data.domains)
|
||||
state.last_load.tags = Date.now()
|
||||
state.last_load.properties = Date.now()
|
||||
state.last_load.categories = Date.now()
|
||||
state.last_load.availability_policies = Date.now()
|
||||
state.last_load.visibility_policies = Date.now()
|
||||
return data
|
||||
},
|
||||
async userIdentityRecord({state}, {password}) {
|
||||
return await serializeIdentityRecord(state.user, state.keypair, password);
|
||||
},
|
||||
async fetchActiveWorkflows({state, commit, dispatch, getters}) {
|
||||
if (state.last_load.active_workflows > Date.now() - 1000 * 60 * 5) { // Cache for 5 minutes
|
||||
return state.active_workflows
|
||||
}
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.get(getters.signAuth, '/api/v1/workflows/')
|
||||
const workflows = data.map(deserializeWorkflowPayload)
|
||||
commit('setActiveWorkflows', workflows)
|
||||
state.last_load.active_workflows = Date.now()
|
||||
return workflows
|
||||
},
|
||||
async createWorkflow({state, commit, dispatch, getters}, workflowData) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
// The backend stores payload as an opaque string; the frontend (de)serializes it.
|
||||
// See docs/implementation.md#workflow-payload-is-an-opaque-string.
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/workflows/', serializeWorkflowPayload(workflowData))
|
||||
state.last_load.active_workflows = 0 // Invalidate cache
|
||||
return deserializeWorkflowPayload(data)
|
||||
},
|
||||
async updateWorkflow({state, commit, dispatch, getters}, workflow) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.patch(getters.signAuth, '/api/v1/workflows/' + workflow.id + '/', serializeWorkflowPayload(workflow))
|
||||
state.last_load.active_workflows = 0 // Invalidate cache
|
||||
return deserializeWorkflowPayload(data)
|
||||
},
|
||||
async updateWorkflowStep({state, commit, dispatch, getters}, {workflowId, currentStep, payload}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/v1/workflows/' + workflowId + '/update_step/', {
|
||||
current_step: currentStep,
|
||||
payload: JSON.stringify(payload)
|
||||
})
|
||||
state.last_load.active_workflows = 0 // Invalidate cache
|
||||
return deserializeWorkflowPayload(data)
|
||||
},
|
||||
async deleteWorkflow({state, commit, dispatch, getters}, workflowId) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/v1/workflows/' + workflowId + '/')
|
||||
state.last_load.active_workflows = 0 // Invalidate cache
|
||||
return true
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
isLoggedIn(state) {
|
||||
|
|
@ -117,7 +830,15 @@ export default createStore({
|
|||
state.local_loaded = true
|
||||
}
|
||||
|
||||
return state.user !== null && state.token !== null;
|
||||
return state.user !== null && (state.token !== null || state.keypair !== null)
|
||||
},
|
||||
allUserProfiles(state) {
|
||||
// Combine user's own profile with all friend profiles
|
||||
const combined = {...state.friendProfiles};
|
||||
if (state.user && state.user_profile) {
|
||||
combined[state.user] = state.user_profile;
|
||||
}
|
||||
return combined;
|
||||
},
|
||||
signAuth(state) {
|
||||
return createSignAuth(state.user, state.keypair.signSk)
|
||||
|
|
@ -128,5 +849,76 @@ export default createStore({
|
|||
nullAuth(state) {
|
||||
return createNullAuth({})
|
||||
},
|
||||
inventory_items(state) {
|
||||
return state.item_map['/'] || []
|
||||
},
|
||||
groupInventoryItems: (state) => (groupHandle) => state.item_map['/' + groupHandle] || [],
|
||||
groupStorageLocations: (state) => (groupHandle) => state.location_map['/' + groupHandle] || [],
|
||||
identityIdByHandle(state) {
|
||||
return Object.fromEntries(state.idmap.identities.map(i => [i.handle, i.id]))
|
||||
},
|
||||
groupIdByHandle(state) {
|
||||
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
|
||||
},
|
||||
identityHandleById(state) {
|
||||
return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.handle]))
|
||||
},
|
||||
groupHandleById(state) {
|
||||
return Object.fromEntries(state.idmap.groups.map(g => [g.id, g.handle]))
|
||||
},
|
||||
loaded_items(state) {
|
||||
return Object.entries(state.item_map).reduce((acc, [url, items]) => {
|
||||
return acc.concat(items)
|
||||
}, [])
|
||||
},
|
||||
notifications(state) {
|
||||
// supported types: error, warning, info, login, success, friend
|
||||
const u = state.unreachable_neighbors.list().map(elem => {
|
||||
return {
|
||||
type: 'error',
|
||||
title: elem.domain + ' unreachable',
|
||||
msg: 'The neighbor ' + elem.domain + ' is currently unreachable. Please try again later.',
|
||||
time: elem.time
|
||||
}
|
||||
})
|
||||
return [...u, {
|
||||
type: 'info',
|
||||
title: 'Welcome to the Toolshed',
|
||||
msg: 'This is a federated social network. You can add friends from other servers and share items with them.',
|
||||
time: Date.now()
|
||||
}, {
|
||||
type: 'warning',
|
||||
title: 'Lorem ipsum',
|
||||
msg: 'Aliquam ex eros, imperdiet vulputate hendrerit et.',
|
||||
time: Date.now() - 1000 * 60 * 60 * 2
|
||||
}, {
|
||||
type: 'login',
|
||||
title: 'Login from 192.186.1.8',
|
||||
time: Date.now() - 1000 * 60 * 60 * 5
|
||||
}, {
|
||||
type: 'friend',
|
||||
title: 'New connection',
|
||||
msg: 'Christina accepted your request.',
|
||||
time: Date.now() - 1000 * 60 * 60 * 14
|
||||
}, {
|
||||
type: 'success',
|
||||
title: 'Lorem ipsum',
|
||||
msg: 'Aliquam ex eros, imperdiet vulputate hendrerit et.',
|
||||
time: Date.now() - 1000 * 60 * 60 * 24
|
||||
}]
|
||||
},
|
||||
getPreference: (state) => (key, fallbackDefault = null) => resolvePreference(key, {
|
||||
devicePreferences: state.devicePreferences,
|
||||
accountPreferences: state.accountPreferences,
|
||||
definitions: state.preferenceDefinitions,
|
||||
}, fallbackDefault),
|
||||
/** Extracts the name from a handle like "git:tools#tag:drill"; returns non-handles unchanged. */
|
||||
getNameFromHandle: () => (handle) => {
|
||||
if (typeof handle !== 'string') {
|
||||
return handle;
|
||||
}
|
||||
const match = handle.match(/#(?:tag|property):(.+)$/);
|
||||
return match ? match[1] : handle;
|
||||
},
|
||||
}
|
||||
})
|
||||
59
frontend/src/tests/SearchBox.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import {mount} from '@vue/test-utils'
|
||||
import SearchBox from '../components/SearchBox.vue'
|
||||
|
||||
test('SearchBox component', async () => {
|
||||
expect(SearchBox).toBeTruthy()
|
||||
|
||||
const mockRoute = {
|
||||
params: {
|
||||
query: 'urltest',
|
||||
}
|
||||
}
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
}
|
||||
|
||||
const wrapper = mount(SearchBox, {
|
||||
props: {},
|
||||
global: {
|
||||
mocks: {
|
||||
$route: mockRoute,
|
||||
$router: mockRouter,
|
||||
}
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/search/urltest')
|
||||
|
||||
mockRouter.push.mockClear()
|
||||
|
||||
await wrapper.find({ref: 'search-text'}).setValue('test2')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/search/test2')
|
||||
|
||||
mockRouter.push.mockClear()
|
||||
|
||||
await wrapper.find({ref: 'search-text'}).setValue('äöüß!§$%&/()=?')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(mockRouter.push).toHaveBeenCalledWith('/search/%C3%A4%C3%B6%C3%BC%C3%9F!%C2%A7%24%25%26%2F()%3D%3F')
|
||||
|
||||
mockRouter.push.mockClear()
|
||||
|
||||
await wrapper.find({ref: 'search-text'}).setValue('')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
|
||||
expect(mockRouter.push).not.toHaveBeenCalled()
|
||||
|
||||
expect(await wrapper.find({ref: 'search-text'}).element).toEqual(document.activeElement)
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot()
|
||||
})
|
||||
9
frontend/src/tests/__snapshots__/SearchBox.js.snap
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SearchBox component 1`] = `
|
||||
"<form class=\\"d-none d-sm-inline-block\\">
|
||||
<div class=\\"input-group input-group-navbar\\"><input type=\\"text\\" class=\\"form-control\\" placeholder=\\"Search…\\" aria-label=\\"Search\\"><button class=\\"btn\\" type=\\"button\\"><svg width=\\"1em\\" height=\\"1em\\" viewBox=\\"0 0 16 16\\" fill=\\"currentColor\\" role=\\"img\\" focusable=\\"false\\" class=\\"bi-valign-middle\\">
|
||||
<path d=\\"M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z\\"></path>
|
||||
</svg></button></div>
|
||||
</form>"
|
||||
`;
|
||||
144
frontend/src/tests/short-id.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import {
|
||||
encodeShortId, decodeShortId, deserializeShortId, serializeShortId,
|
||||
encodeDomainQualifiedShortId, decodeDomainQualifiedShortId, isDomainQualifiedShortId
|
||||
} from '../short-id.js'
|
||||
|
||||
test('encodes the worked example from docs/handles-and-shortids.md', () => {
|
||||
const token = encodeShortId([0, 7, 42])
|
||||
expect(token).toBe('~DyU')
|
||||
})
|
||||
|
||||
test('kind ids are scoped per arity: id 0 means item (arity 2) or category (arity 1)', () => {
|
||||
expect(deserializeShortId(decodeShortId(encodeShortId([0, 7, 42])))).toEqual({
|
||||
kind: 'item', owner_identity_id: 7, item_local_id: 42
|
||||
})
|
||||
expect(deserializeShortId(decodeShortId(encodeShortId([0, 5])))).toEqual({
|
||||
kind: 'category', category_id: 5
|
||||
})
|
||||
})
|
||||
|
||||
test('every kind round-trips through serialize/encode/decode/deserialize', () => {
|
||||
const cases = [
|
||||
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
||||
{kind: 'category', category_id: 5},
|
||||
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
|
||||
{kind: 'group', group_id: 11},
|
||||
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
||||
{kind: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000},
|
||||
{kind: 'file', file_id: 123},
|
||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||
]
|
||||
for (const named of cases) {
|
||||
const token = encodeShortId(serializeShortId(named))
|
||||
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
||||
}
|
||||
})
|
||||
|
||||
test('a non-last field may be 0 - only the last field is restricted', () => {
|
||||
const named = {kind: 'item', owner_identity_id: 0, item_local_id: 42}
|
||||
const token = encodeShortId(serializeShortId(named))
|
||||
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
||||
})
|
||||
|
||||
test('rejects a 0 last-field value, for a single-field kind', () => {
|
||||
expect(() => encodeShortId(serializeShortId({kind: 'category', category_id: 0}))).toThrow()
|
||||
})
|
||||
|
||||
test('rejects a 0 last-field value, for a multi-field kind', () => {
|
||||
expect(() => encodeShortId(serializeShortId({kind: 'item', owner_identity_id: 7, item_local_id: 0}))).toThrow()
|
||||
})
|
||||
|
||||
test('decoding never over-reads: a single-chunk field that exactly exhausts padding is not ' +
|
||||
'mistaken for a second field', () => {
|
||||
// category_id 5 -> 7 bits + 5 zero padding bits, identical to a genuine one-chunk zero field - the case the last-field-nonzero rule disambiguates.
|
||||
const token = encodeShortId([0, 5])
|
||||
expect(token).toBe('~Cg')
|
||||
expect(decodeShortId(token)).toEqual([0, 5])
|
||||
})
|
||||
|
||||
test('round-trips small and large nonzero values, in both last and non-last field position', () => {
|
||||
const values = [1, 7, 42, 100, 10000, 100000, 123456789]
|
||||
for (const value of values) {
|
||||
expect(decodeShortId(encodeShortId([0, value]))).toEqual([0, value])
|
||||
expect(decodeShortId(encodeShortId([0, value, 1]))).toEqual([0, value, 1])
|
||||
}
|
||||
})
|
||||
|
||||
test('tokens are URL-safe: only unreserved characters, plus the leading ~', () => {
|
||||
const token = encodeShortId([0, 123456, 987654])
|
||||
expect(token[0]).toBe('~')
|
||||
expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/)
|
||||
})
|
||||
|
||||
test('kinds at escaped ids (3+) round-trip through the escape tag, not just the direct 0-2 range', () => {
|
||||
const token = encodeShortId(serializeShortId({kind: 'workflow', owner_identity_id: 2, workflow_id: 9}))
|
||||
expect(deserializeShortId(decodeShortId(token))).toEqual({
|
||||
kind: 'workflow', owner_identity_id: 2, workflow_id: 9
|
||||
})
|
||||
})
|
||||
|
||||
test('serializeShortId then deserializeShortId round-trips through encode/decode', () => {
|
||||
const token = encodeShortId(serializeShortId({kind: 'item', owner_identity_id: 7, item_local_id: 42}))
|
||||
expect(token).toBe('~DyU')
|
||||
expect(deserializeShortId(decodeShortId(token))).toEqual({
|
||||
kind: 'item',
|
||||
owner_identity_id: 7,
|
||||
item_local_id: 42
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects a token missing the leading ~', () => {
|
||||
expect(() => decodeShortId('DyU')).toThrow()
|
||||
})
|
||||
|
||||
test('rejects an unknown kind index', () => {
|
||||
expect(() => encodeShortId([99, 1])).toThrow()
|
||||
})
|
||||
|
||||
test('rejects an arity with no registered schema at that kind index', () => {
|
||||
// kind 0 is registered for arity 2 (item) and arity 1 (category), but not arity 3.
|
||||
expect(() => encodeShortId([0, 7, 8, 9])).toThrow()
|
||||
})
|
||||
|
||||
test('rejects an unknown kind name', () => {
|
||||
expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow()
|
||||
})
|
||||
|
||||
test('rejects a missing field', () => {
|
||||
expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow()
|
||||
})
|
||||
|
||||
test('rejects a negative field value', () => {
|
||||
expect(() => encodeShortId([0, -1, 42])).toThrow()
|
||||
})
|
||||
|
||||
test('encodes a Domain-Qualified Short ID as "<domain>:<token>"', () => {
|
||||
expect(encodeDomainQualifiedShortId('toolsheddomain.tld', [0, 7, 42])).toBe('toolsheddomain.tld:~DyU')
|
||||
})
|
||||
|
||||
test('decodes a Domain-Qualified Short ID back into its domain and ints', () => {
|
||||
const decoded = decodeDomainQualifiedShortId('toolsheddomain.tld:~DyU')
|
||||
expect(decoded.domain).toBe('toolsheddomain.tld')
|
||||
expect(decoded.token).toBe('~DyU')
|
||||
expect(decoded.ints).toEqual([0, 7, 42])
|
||||
})
|
||||
|
||||
test('round-trips a Domain-Qualified Short ID through encode/decode', () => {
|
||||
const named = {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}
|
||||
const encoded = encodeDomainQualifiedShortId('example.com', serializeShortId(named))
|
||||
const {domain, ints} = decodeDomainQualifiedShortId(encoded)
|
||||
expect(domain).toBe('example.com')
|
||||
expect(deserializeShortId(ints)).toEqual(named)
|
||||
})
|
||||
|
||||
test('rejects a Domain-Qualified Short ID missing the "~" after the domain', () => {
|
||||
expect(() => decodeDomainQualifiedShortId('toolsheddomain.tldDyU')).toThrow()
|
||||
expect(() => decodeDomainQualifiedShortId('~DyU')).toThrow()
|
||||
})
|
||||
|
||||
test('isDomainQualifiedShortId tells a "<domain>:~token" apart from a bare token', () => {
|
||||
expect(isDomainQualifiedShortId('toolsheddomain.tld:~DyU')).toBe(true)
|
||||
expect(isDomainQualifiedShortId('~DyU')).toBe(false)
|
||||
expect(isDomainQualifiedShortId('alice@example.com:i42')).toBe(false)
|
||||
expect(isDomainQualifiedShortId('not a short id at all')).toBe(false)
|
||||
})
|
||||
127
frontend/src/views/Admin.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<BaseLayout>
|
||||
<main class="content">
|
||||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Admin</h1>
|
||||
<div class="row">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Tags</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="tag in tags.sort()" :key="tag.id">
|
||||
{{ tag }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Properties</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="property in properties.sort()" :key="property.id">
|
||||
{{ property.name }} ({{ property.unit_name }}, {{ property.unit_symbol }})
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Categories</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="category in categories.sort()" :key="category.id">
|
||||
{{ category }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Domains</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="domain in domains.sort()" :key="domain.id">
|
||||
{{ domain }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Availability Policies</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="policy in availability_policies.sort()" :key="policy.id">
|
||||
{{ policy.text }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Visibility Policies</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="policy in visibility_policies.sort()" :key="policy.id">
|
||||
{{ policy.text }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Storage Locations</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="location in storage_locations.sort()" :key="location.id">
|
||||
{{ location.path }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
|
||||
export default {
|
||||
name: "Admin",
|
||||
components: {
|
||||
BaseLayout,
|
||||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
...mapState(["tags", "properties", "categories", "availability_policies", "visibility_policies", "domains", "storage_locations"])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchInfo", "fetchStorageLocations"])
|
||||
},
|
||||
async mounted() {
|
||||
await this.fetchInfo();
|
||||
await this.fetchStorageLocations();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
|
@ -5,7 +5,16 @@
|
|||
<h1 class="h3 mb-3">Dashboard</h1>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Empty card</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="logo">
|
||||
<img src="/src/assets/icons/toolshed-48x48.png" alt="Toolshed logo">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -18,7 +27,7 @@ import * as BIcons from "bootstrap-icons-vue";
|
|||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
|
||||
export default {
|
||||
name: 'Index',
|
||||
name: 'Dashboard',
|
||||
components: {
|
||||
...BIcons,
|
||||
BaseLayout
|
||||
75
frontend/src/views/Files.vue
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<BaseLayout>
|
||||
<main class="content">
|
||||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Files</h1>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Images</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<span v-for="file in only_images(files)" :key="file.id">
|
||||
<deletable-wrapper @delete="deleteFile(file)">
|
||||
<authenticated-image :src="thumbnailPathForHash(file.hash)" :owner="file.owner"
|
||||
class="img-thumbnail"/>
|
||||
</deletable-wrapper>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Other files</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="file in whithout_images(files)" :key="file.id">
|
||||
{{ file.name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import DeletableWrapper from "@/components/DeletableWrapper.vue";
|
||||
|
||||
export default {
|
||||
name: "Files",
|
||||
components: {
|
||||
AuthenticatedImage,
|
||||
BaseLayout,
|
||||
DeletableWrapper,
|
||||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
...mapState(["files"])
|
||||
},
|
||||
methods: {
|
||||
...mapActions(["fetchFiles", "deleteFile"]),
|
||||
only_images(files) {
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
},
|
||||
whithout_images(files) {
|
||||
return files.filter(file => !file.mime_type.startsWith("image/"));
|
||||
},
|
||||
// See docs/implementation.md#thumbnail-lookup-by-hash.
|
||||
thumbnailPathForHash(hash, size = 256) {
|
||||
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchFiles();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
200
frontend/src/views/Friends.vue
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<template>
|
||||
<BaseLayout>
|
||||
<main class="content">
|
||||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">Friends</h1>
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">All Friends</h5>
|
||||
</div>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="d-none d-md-table-cell" style="width:25%">Server</th>
|
||||
<th style="width: 16em">
|
||||
<a @click="fetchContent" class="align-middle">
|
||||
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||
Refresh
|
||||
</a>
|
||||
<a @click="showNewFriend" class="align-middle">
|
||||
<b-icon-plus></b-icon-plus>
|
||||
Add friend
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="show_newfriend">
|
||||
<td colspan="2">
|
||||
<input type="text" class="form-control" placeholder="user@domain"
|
||||
v-model="newfriend">
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" @click="tryRequestFriend">Send request</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="friend in friendslist" :key="friend.name">
|
||||
<td>
|
||||
<user-name-tag :user="friend"/>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ friend.server.join(', ')}}</td>
|
||||
<td class="table-action">
|
||||
<!--a href="#" class="align-middle">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
Edit
|
||||
</a-->
|
||||
<a href="#" class="align-middle" @click="tryDropFriend(friend)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Delete
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Open friend requests</h5>
|
||||
</div>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="d-none d-md-table-cell" style="width:25%">Key</th>
|
||||
<th style="width: 16em">
|
||||
<a @click="fetchContent" class="align-middle">
|
||||
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||
Refresh
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="request in requests" :key="request.befriender">
|
||||
<td>{{ request.befriender }}</td>
|
||||
<td class="d-none d-md-table-cell">
|
||||
{{ request.befriender_public_key?.slice(0, 32) ?? 'N/A' }}...
|
||||
</td>
|
||||
<td class="table-action">
|
||||
<button class="btn btn-sm btn-success" @click="tryAcceptFriend(request)">
|
||||
<b-icon-check></b-icon-check>
|
||||
Accept
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" @click="tryDeclineFriend(request)">
|
||||
<b-icon-x></b-icon-x>
|
||||
Decline
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
|
||||
export default {
|
||||
name: 'Inventory',
|
||||
components: {
|
||||
BaseLayout,
|
||||
AuthenticatedImage,
|
||||
UserNameTag,
|
||||
...BIcons
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
friends: {},
|
||||
show_newfriend: false,
|
||||
newfriend: "",
|
||||
requests: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['user']),
|
||||
username() {
|
||||
return this.$route.params.username
|
||||
},
|
||||
friendslist() {
|
||||
return Object.entries(this.friends).map(([_, friend]) => friend)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchFriends', "lookupServer", "requestFriend", "acceptFriend", "fetchFriendRequests",
|
||||
"declineFriend", "dropFriend", "getFriendServers", "fetchFriendProfile"]),
|
||||
fetchContent() {
|
||||
this.fetchFriends().then((friends) => {
|
||||
// Handle both object and array formats
|
||||
const friendsList = Array.isArray(friends) ? friends : Object.values(friends);
|
||||
friendsList.map((friend) => {
|
||||
this.lookupServer({username: friend.handle}).then((server) => {
|
||||
this.friends[friend.handle] = {...friend, server: server}
|
||||
this.fetchFriendProfile({username: friend.handle});
|
||||
})
|
||||
})
|
||||
})
|
||||
this.fetchFriendRequests().then((requests) => {
|
||||
this.requests = requests
|
||||
})
|
||||
},
|
||||
showNewFriend() {
|
||||
this.show_newfriend = true
|
||||
},
|
||||
tryRequestFriend() {
|
||||
this.requestFriend({username: this.newfriend}).then((ok) => {
|
||||
if (ok) {
|
||||
this.show_newfriend = false
|
||||
this.newfriend = ""
|
||||
this.fetchContent()
|
||||
}
|
||||
}).catch(() => {
|
||||
})
|
||||
},
|
||||
tryAcceptFriend(request) {
|
||||
this.acceptFriend({id: request.id, secret: request.secret, befriender: request.befriender}).then((ok) => {
|
||||
if (ok) {
|
||||
this.fetchContent()
|
||||
}
|
||||
}).catch(() => {
|
||||
})
|
||||
},
|
||||
tryDropFriend(friend) {
|
||||
this.dropFriend(friend).then((ok) => {
|
||||
if (ok) {
|
||||
delete this.friends[friend.handle]
|
||||
}
|
||||
}).catch(() => {
|
||||
})
|
||||
},
|
||||
tryDeclineFriend(request) {
|
||||
this.declineFriend(request).then((ok) => {
|
||||
if (ok) {
|
||||
this.fetchContent()
|
||||
}
|
||||
}).catch(() => {
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.fetchContent()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
124
frontend/src/views/GroupDetail.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<template>
|
||||
<BaseLayout>
|
||||
<main class="content">
|
||||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">{{ group ? group.handle : '' }}</h1>
|
||||
<div class="row" v-if="group">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title">Members</h5>
|
||||
</div>
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style="width: 16em">
|
||||
<a @click="refresh" class="align-middle">
|
||||
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||
Refresh
|
||||
</a>
|
||||
<a @click="show_invite = true" class="align-middle">
|
||||
<b-icon-plus></b-icon-plus>
|
||||
Add member
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="show_invite">
|
||||
<td>
|
||||
<input type="text" class="form-control" placeholder="user@domain"
|
||||
v-model="invitee">
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" @click="tryInvite">Invite</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="member in group.members" :key="member.id">
|
||||
<td>
|
||||
<user-name-tag :user="member"/>
|
||||
</td>
|
||||
<td class="table-action">
|
||||
<a href="#" class="align-middle" @click.prevent="tryRemoveMember(member)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Remove
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
import {decodeHandleFromUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
name: 'GroupDetail',
|
||||
components: {
|
||||
BaseLayout,
|
||||
UserNameTag,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
handle: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
group: null,
|
||||
show_invite: false,
|
||||
invitee: ""
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
decodedHandle() {
|
||||
return decodeHandleFromUrl(this.handle)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup']),
|
||||
// Loads by handle regardless of which backend actually hosts the group - see
|
||||
// store.js's fetchGroup.
|
||||
refresh() {
|
||||
return this.fetchGroup({handle: this.decodedHandle}).then((group) => {
|
||||
this.group = group
|
||||
})
|
||||
},
|
||||
tryInvite() {
|
||||
this.inviteToGroup({groupHandle: this.group.handle, invitee: this.invitee}).then((ok) => {
|
||||
if (ok) {
|
||||
this.show_invite = false
|
||||
this.invitee = ""
|
||||
}
|
||||
}).catch(() => {
|
||||
})
|
||||
},
|
||||
tryRemoveMember(member) {
|
||||
this.removeGroupMember({groupHandle: this.group.handle, identityId: member.id})
|
||||
.then(() => {
|
||||
this.refresh()
|
||||
}).catch(() => {
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refresh()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
53
frontend/src/views/GroupNew.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<BaseLayout>
|
||||
<main class="content">
|
||||
<div class="container-fluid p-0">
|
||||
<h1 class="h3 mb-3">New group</h1>
|
||||
<div class="row">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" v-model="name">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%"
|
||||
@click="tryCreateGroup">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</BaseLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions} from "vuex";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {encodeHandleForUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
name: 'GroupNew',
|
||||
components: {
|
||||
BaseLayout
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: ""
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['createGroup']),
|
||||
tryCreateGroup() {
|
||||
this.createGroup({name: this.name}).then((group) => {
|
||||
this.$router.push(`/groups/${encodeHandleForUrl(group.handle)}`)
|
||||
}).catch(() => {
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||