Snapshot: alpha-2026-9

This commit is contained in:
j3d1 2026-09-02 21:40:28 +02:00
parent 9acf5a97e2
commit d00b5c7961
241 changed files with 85546 additions and 2409 deletions

File diff suppressed because it is too large Load diff

View file

Binary file not shown.

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 699 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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="&#xf004;"
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="&#xf005;" 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="&#xf007;" 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="&#xf017;"
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="&#xf022;"
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="&#xf024;"
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="&#xf02e;" 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="&#xf03e;"
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="&#xf044;" 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="&#xf057;"
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="&#xf058;"
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="&#xf059;"
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="&#xf06e;" 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="&#xf070;" 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="&#xf073;" 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="&#xf075;"
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="&#xf07b;"
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="&#xf07c;" 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="&#xf080;"
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="&#xf086;" 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="&#xf089;" 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="&#xf094;"
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="&#xf09d;" 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="&#xf0a0;" 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="&#xf0a4;"
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="&#xf0a5;"
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="&#xf0a6;" 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="&#xf0a7;" 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="&#xf0c5;" 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="&#xf0c7;" 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="&#xf0c8;" 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="&#xf0e0;"
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="&#xf0eb;" 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="&#xf0f3;" 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="&#xf0f8;" 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="&#xf0fe;" 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="&#xf111;"
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="&#xf118;" 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="&#xf119;" 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="&#xf11a;" 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="&#xf11c;" 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="&#xf133;" 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="&#xf144;"
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="&#xf146;" 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="&#xf14a;" 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="&#xf14d;" 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="&#xf14e;" 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="&#xf150;" 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="&#xf151;" 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="&#xf152;" 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="&#xf15b;" 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="&#xf15c;" 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="&#xf164;"
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="&#xf165;"
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="&#xf185;"
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="&#xf186;"
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="&#xf191;" 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="&#xf192;"
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="&#xf1ad;" 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="&#xf1c1;" 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="&#xf1c2;" 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="&#xf1c3;" 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="&#xf1c4;" 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="&#xf1c5;" 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="&#xf1c6;" 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="&#xf1c7;" 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="&#xf1c8;" 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="&#xf1c9;" 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="&#xf1cd;"
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="&#xf1d8;"
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="&#xf1e3;" 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="&#xf1ea;" 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="&#xf1f6;" 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="&#xf1f9;"
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="&#xf20a;"
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="&#xf247;"
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="&#xf248;" 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="&#xf249;" 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="&#xf24d;"
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="&#xf254;" 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="&#xf255;"
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="&#xf256;" 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="&#xf257;"
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="&#xf258;" 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="&#xf259;"
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="&#xf25a;" 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="&#xf25b;" 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="&#xf25d;"
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="&#xf271;" 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="&#xf272;" 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="&#xf273;" 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="&#xf274;" 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="&#xf279;" 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="&#xf27a;"
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="&#xf28b;"
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="&#xf28d;"
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="&#xf2b5;" 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="&#xf2b6;"
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="&#xf2b9;" 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="&#xf2bb;" 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="&#xf2bd;" 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="&#xf2c1;" 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="&#xf2c2;" 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="&#xf2d0;"
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="&#xf2d1;"
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="&#xf2d2;"
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="&#xf2dc;" 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="&#xf2ed;" 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="&#xf302;" 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="&#xf328;" 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="&#xf358;"
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="&#xf359;"
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="&#xf35a;"
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="&#xf35b;"
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="&#xf3a5;" 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="&#xf3d1;" 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="&#xf410;"
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="&#xf4ad;"
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="&#xf4da;" 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="&#xf556;" 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="&#xf567;" 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="&#xf579;" 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="&#xf57a;" 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="&#xf57f;" 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="&#xf580;" 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="&#xf581;" 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="&#xf582;" 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="&#xf583;" 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="&#xf584;" 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="&#xf585;" 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="&#xf586;"
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="&#xf587;" 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="&#xf588;" 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="&#xf589;" 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="&#xf58a;" 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="&#xf58b;" 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="&#xf58c;" 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="&#xf596;" 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="&#xf597;" 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="&#xf598;" 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="&#xf599;" 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="&#xf59a;" 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="&#xf59b;" 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="&#xf59c;" 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="&#xf5a4;" 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="&#xf5a5;" 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="&#xf5b3;" 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="&#xf5b4;" 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="&#xf5b8;" 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="&#xf5c2;" 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="&#xf5c8;" 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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

25141
frontend/src/assets/js/app.js Normal file

File diff suppressed because one or more lines are too long

View 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();

View 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>

View file

@ -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>

View 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>

View 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>

View 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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>&nbsp;Capture
</button>
<button
class="btn btn-danger"
:class="{'disabled': !dataImage}"
@click="(dataImage) && retake()"
>
<b-icon-trash></b-icon-trash>&nbsp;Retake
</button>
<button
class="btn btn-primary"
:class="{'disabled': !dataImage}"
@click="(dataImage) && save()"
>
<b-icon-save></b-icon-save>&nbsp;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>

View 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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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'];

View file

@ -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
View 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
View 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}

View 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
View 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)});
}

View file

@ -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
View 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;
}

View 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,
};

View 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();

View file

@ -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},
}
}

View file

@ -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 {

View 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);
}

View file

@ -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;
}

View 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;
}

View file

@ -0,0 +1,3 @@
.badge {
color: #fff;
}

View file

@ -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
View 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]
}

View file

@ -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;
},
}
})

View 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()
})

View 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>"
`;

View 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)
})

View 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>

View file

@ -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

View 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>

View 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> &nbsp;
<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>

View 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>

View 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>

View file

@ -0,0 +1,146 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Groups</h1>
<div class="row">
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">Groups</h5>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th class="d-none d-md-table-cell" style="width:25%">Members</th>
<th style="width: 16em">
<a @click="refreshGroups" class="align-middle">
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
Refresh
</a>
<router-link to="/groups/new" class="align-middle">
<b-icon-plus></b-icon-plus>
New group
</router-link>
</th>
</tr>
</thead>
<tbody>
<tr v-for="group in allGroups" :key="group.key">
<td>
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`">
{{ group.handle }}
</router-link>
</td>
<!-- GroupDetail.vue loads a foreign group's roster live by handle (see
store.js's fetchGroup) - this list just avoids an N-request roundtrip
per row, so a member count is only free for a group hosted here. -->
<td class="d-none d-md-table-cell">{{ group.hosted ? group.memberCount : '—' }}</td>
<td class="table-action"></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 group invites</h5>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Group</th>
<th class="d-none d-md-table-cell" style="width:25%">Invited by</th>
<th style="width: 16em">
<a @click="fetchGroupInvites" class="align-middle">
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
Refresh
</a>
</th>
</tr>
</thead>
<tbody>
<tr v-for="invite in groupInvites" :key="invite.id">
<td>{{ invite.group }}</td>
<td class="d-none d-md-table-cell">{{ invite.inviter }}</td>
<td class="table-action">
<button class="btn btn-sm btn-success" @click="tryAcceptInvite(invite)">
<b-icon-check></b-icon-check>
Accept
</button> &nbsp;
<button class="btn btn-sm btn-danger" @click="tryDeclineInvite(invite)">
<b-icon-x></b-icon-x>
Decline
</button>
</td>
</tr>
</tbody>
</table>
</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";
import {encodeHandleForUrl} from "@/router";
export default {
name: 'Groups',
components: {
BaseLayout,
...BIcons
},
computed: {
...mapState(['groups', 'groupInvites', 'groupMemberships']),
allGroups() {
const hosted = this.groups.map(group => ({
key: `hosted:${group.id}`, handle: group.handle, memberCount: group.members.length, hosted: true
}))
const hostedHandles = new Set(hosted.map(group => group.handle))
const foreign = this.groupMemberships
.filter(membership => !hostedHandles.has(membership.handle))
.map(membership => ({key: `foreign:${membership.id}`, handle: membership.handle, hosted: false}))
return [...hosted, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
},
},
methods: {
encodeHandleForUrl,
...mapActions(['fetchGroups', 'fetchGroupInvites', 'fetchGroupMemberships', 'acceptGroupInvite',
'declineGroupInvite']),
refreshGroups() {
this.fetchGroups()
this.fetchGroupMemberships()
},
tryAcceptInvite(invite) {
this.acceptGroupInvite(invite).then(() => {
this.fetchGroupInvites()
this.fetchGroups()
this.fetchGroupMemberships()
}).catch(() => {
})
},
tryDeclineInvite(invite) {
this.declineGroupInvite(invite).then(() => {
this.fetchGroupInvites()
}).catch(() => {
})
}
},
mounted() {
this.fetchGroups()
this.fetchGroupInvites()
this.fetchGroupMemberships()
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,251 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Inventory</h1>
<div class="row">
<div class="col-12 col-xl-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
<h5 class="card-title mb-0">{{ selectedOwner }}'s Inventory</h5>
<div class="d-flex align-items-center">
<!--label for="ownerSelect" class="visually-hidden">Viewing inventory for</label-->
<select id="ownerSelect" class="form-select form-select-sm me-2" style="width:auto"
v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
<option v-for="friend in friends" :value="friend.handle" :key="friend.handle">
{{ friend.handle }}
</option>
</select>
<div class="btn-group">
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
</div>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
<tr>
<th style="width:35%;">Name</th>
<th style="width:20%">Availability Policy</th>
<th style="width:20%">Visibility Policy</th>
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td>
<router-link :to="itemRoute(item)">{{ item.name }}</router-link>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ item.visibility_policy }}</span>
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a v-if="canEdit" :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash>
</a>
<!--router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
<b-icon-link></b-icon-link>
</router-link-->
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
</table>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in items"
:key="item.id">
<div class="card">
<img :src="item.image" class="" :alt="item.image">
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview"/>
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="itemRoute(item)">
{{ item.name }}
</router-link></h5>
<div class="card-text text-black-50">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
<span class="badge bg-secondary text-white">{{ item.visibility_policy }}</span>
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
<div class="btn-group">
<button v-if="canEdit" class="btn btn-danger btn-sm"
@click="tryDeleteItem(item)">Delete
</button>
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
<!--router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link>
</router-link-->
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
class="btn btn-secondary btn-sm">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
export default {
name: "Inventory",
props: {
// Matches /inventory/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
owner: {
type: String,
default: null
}
},
components: {
AuthenticatedImage,
BaseLayout,
...BIcons
},
computed: {
...mapGetters(["inventory_items", "groupInventoryItems", "loaded_items", "identityIdByHandle", "groupIdByHandle", "getPreference"]),
...mapState(["user", "storage_locations", "groups", "groupMemberships", "friends"]),
// Implicit preference: remembers the last-used view without a settings-page entry. See docs/implementation.md#preferences.
layout: {
get() {
return this.getPreference('ui.remembered.inventory_view_mode', 'grid')
},
set(value) {
this.setDevicePreference({key: 'ui.remembered.inventory_view_mode', value})
}
},
// Groups hosted here plus GroupMembership-only ones - see Groups.vue's allGroups for the same merge/dedupe.
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))
},
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
selectedOwner: {
get() {
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
},
set(value) {
const path = value === this.user ? '/inventory' : `/inventory/${encodeHandleForUrl(value)}`
this.$router.replace(path)
}
},
isGroupSelected() {
return !!this.selectedOwner && this.selectedOwner.startsWith('#')
},
// Friend items are browse-only - inventory.py's perform_create/update/destroy reject writes from anyone but the owner/a fellow group member.
canEdit() {
return this.selectedOwner === this.user || this.isGroupSelected
},
items() {
// item_map is keyed by owner handle regardless of group vs friend - see store.js's groupInventoryItems.
return this.selectedOwner === this.user ? this.inventory_items : this.groupInventoryItems(this.selectedOwner)
},
addItemRoute() {
return this.selectedOwner === this.user
? '/inventory/new'
: `/inventory/new/${encodeHandleForUrl(this.selectedOwner)}`
}
},
methods: {
...mapActions(["fetchInventoryItems", "fetchGroupInventoryItems", "fetchFriendInventoryItems",
"deleteInventoryItem", "fetchStorageLocations", "fetchIdMap", "fetchGroups",
"fetchGroupMemberships", "fetchFriends", "setDevicePreference"]),
fetchItemsForOwner() {
if (this.selectedOwner === this.user) return this.fetchInventoryItems()
if (this.isGroupSelected) return this.fetchGroupInventoryItems({groupHandle: this.selectedOwner})
return this.fetchFriendInventoryItems({friendHandle: this.selectedOwner})
},
tryDeleteItem(item) {
this.deleteInventoryItem(item).then(() => {
this.fetchItemsForOwner()
})
},
// Derived from the item's own owner, not the current selection, so the link stays correct if the dropdown changes underneath it.
itemRoute(item) {
return `/inventory/${encodeHandleForUrl(item.owner_group || item.owner)}/${item.id}`
},
only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/"));
},
locationPath(item) {
const loc = this.storage_locations.find(loc => loc.id === item.storage_location)
return loc ? loc.path : null
},
shortIdLink(item) {
if (item.owner_group) {
const owner_group_id = this.groupIdByHandle[item.owner_group]
if (owner_group_id === undefined) return null
return shortenedRoute({kind: 'group_item', owner_group_id, item_local_id: item.id})
}
const owner_identity_id = this.identityIdByHandle[item.owner]
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
},
printLinkFor(item) {
const userHandle = item.owner || item.owner_group
if (!userHandle) return null
return {path: '/print', query: {kind: 'item', userHandle, id: item.id}}
},
},
watch: {
owner() {
this.fetchItemsForOwner()
}
},
async mounted() {
this.fetchItemsForOwner()
await this.fetchStorageLocations()
await this.fetchIdMap()
await this.fetchGroups()
await this.fetchGroupMemberships()
await this.fetchFriends()
}
}
</script>
<style scoped>
.img-preview {
object-fit: cover;
}
</style>

View file

@ -0,0 +1,140 @@
<template>
<BaseLayout>
<main class="content">
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">{{ item.name }}</div>
<div class="card-body">
<div class="mb-3">
<label for="owner" class="form-label">Owner</label>
{{ item.owner || item.owner_group }}
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
{{ item.description }}
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<span class="badge bg-dark" v-for="(tag, index) in item.tags" :key="index">
{{ getNameFromHandle(tag) }}
</span>
</div>
<div class="mb-3">
<label for="property" class="form-label">Properties</label>
<span class="badge bg-dark" v-for="(property, index) in item.properties" :key="index">
{{ property.name }}={{ property.value }}
</span>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
{{ item.owned_quantity }}
</div>
<div class="mb-3">
<label for="image" class="form-label">Image</label>
<div>
<authenticated-image v-for="file in item.files" :key="file.id" :src="file.name"
:owner="file.owner" class="img-thumbnail border-info"></authenticated-image>
</div>
</div>
</div>
</div>
<div class="card" v-if="canEdit">
<button class="btn btn-primary" @click="$router.push(`/inventory/${handle}/${id}/edit`)">
<b-icon-pencil-square></b-icon-pencil-square>
Edit
</button>
<button type="submit" class="btn btn-danger"
@click="deleteInventoryItem(item).then(() => $router.push(ownerOverviewRoute(item)))">
<b-icon-trash></b-icon-trash>
Delete
</button>
<button v-if="canPrint" class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, id: id}})">
<b-icon-printer></b-icon-printer>
Print label
</button>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "InventoryDetail",
components: {
AuthenticatedImage,
BaseLayout,
...BIcons
},
props: {
handle: {
type: String,
required: true
},
id: {
type: String,
required: true
}
},
data() {
return {
item: {}
}
},
computed: {
...mapGetters(["getNameFromHandle", "groupIdByHandle"]),
...mapState(["user", "groupMemberships"]),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
// Client-side hint only; the backend hosting the group is what actually authorizes the write. See docs/implementation.md#group-membership-is-recorded-on-both-sides-like-friendship.
isGroupMember() {
return this.decodedHandle in this.groupIdByHandle
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
},
// Edit/Delete require actual authorization; get_queryset()'s friends_or_self() lets a friend view but never act. See docs/implementation.md#owner-handle-scoped-routes.
canEdit() {
return this.decodedHandle === this.user || this.isGroupMember
},
canPrint() {
return this.decodedHandle === this.user || this.isGroupMember
}
},
methods: {
ownerOverviewRoute,
...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]),
async loadItem() {
this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {}
}
},
watch: {
handle: 'loadItem',
id: 'loadItem'
},
mounted() {
this.loadItem()
if (this.decodedHandle.startsWith('#')) {
this.fetchGroupMemberships()
}
}
}
</script>
<style scoped>
img {
width: 190px;
height: 107px;
object-fit: contain;
}
</style>

View file

@ -0,0 +1,156 @@
<template>
<BaseLayout>
<main class="content">
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">Edit Item</div>
<div class="card-body">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name"
placeholder="Enter item name" v-model="item.name">
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description"
placeholder="Enter description" v-model="item.description"></textarea>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<tag-field :value="item.tags"></tag-field>
</div>
<div class="mb-3">
<label for="property" class="form-label">Property</label>
<property-field :value="item.properties"></property-field>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="quantity" name="quantity"
placeholder="Enter quantity" v-model="item.owned_quantity">
</div>
<div class="mb-3">
<label for="availablity_policy" class="form-label">Availablity Policy</label>
<select class="form-select" id="availablity_policy" name="availablity_policy"
v-model="item.availability_policy">
<option v-for="policy in availability_policies" :value="policy.slug"
:selected="policy.slug === item.availability_policy">
{{ policy.text }}
</option>
</select>
</div>
<div class="mb-3">
<label for="visibility_policy" class="form-label">Visibility Policy</label>
<select class="form-select" id="visibility_policy" name="visibility_policy"
v-model="item.visibility_policy">
<option v-for="policy in visibility_policies" :value="policy.slug"
:selected="policy.slug === item.visibility_policy">
{{ policy.text }}
</option>
</select>
</div>
<div class="mb-3">
<label for="storage_location" class="form-label">Storage Location</label>
<select class="form-select" id="storage_location" name="storage_location"
v-model="item.storage_location">
<option :value="null">No storage location</option>
<option v-for="location in storage_locations" :value="location.id"
:key="location.id">
{{ location.path }}
</option>
</select>
</div>
<div class="mb-3">
<label for="image" class="form-label">Image</label>
<combined-file-field :item_files="item.files" :item_id="item.id"
@change="changeFiles"></combined-file-field>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary" style="width: 100%"
@click="updateInventoryItem(item).then(updated => $router.push(ownerOverviewRoute(updated)))">
Update
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import TagField from "@/components/TagField.vue";
import PropertyField from "@/components/PropertyField.vue";
import CombinedFileField from "@/components/CombinedFileField.vue";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "InventoryEdit",
components: {
BaseLayout,
TagField,
PropertyField,
CombinedFileField,
...BIcons
},
props: {
handle: {
type: String,
required: true
},
id: {
type: String,
required: true
}
},
data() {
return {
// Fetched fresh by {handle, id} on mount rather than looked up by id alone -
// loaded_items mixes personal/group/search fetches, and id is only unique per owner
// (see InventoryDetail.vue).
item: {
tags: [],
properties: [],
name: "",
description: "",
owned_quantity: 0,
image: "",
files: [],
storage_location: null,
}
}
},
computed: {
...mapState(["availability_policies", "visibility_policies", "storage_locations"]),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
}
},
methods: {
ownerOverviewRoute,
...mapActions(["fetchItemByHandle", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
changeFiles(files) {
this.item.files = files
},
async loadItem() {
const loaded = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id})
if (loaded) {
this.item = {...this.item, ...loaded}
}
}
},
async mounted() {
await this.fetchInfo();
await this.loadItem();
await this.fetchStorageLocations();
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,163 @@
<template>
<BaseLayout>
<main class="content">
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">Create New Item</div>
<div class="card-body">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name"
placeholder="Enter item name" v-model="item.name">
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description"
placeholder="Enter description" v-model="item.description"></textarea>
</div>
<div class="mb-3">
<label for="tags" class="form-label">Tags</label>
<tag-field :value="item.tags"></tag-field>
</div>
<div class="mb-3">
<label for="property" class="form-label">Property</label>
<property-field :value="item.properties"></property-field>
</div>
<div class="mb-3">
<label for="owner" class="form-label">Owner</label>
<select class="form-select" id="owner" name="owner" v-model="item.owner_group">
<option :value="null">Myself</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
</select>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="quantity" name="quantity"
placeholder="Enter quantity" v-model="item.owned_quantity">
</div>
<div class="mb-3">
<label for="availablity_policy" class="form-label">Availablity Policy</label>
<select class="form-select" id="availablity_policy" name="availablity_policy"
v-model="item.availability_policy">
<option v-for="policy in availability_policies" :value="policy.slug"
:selected="policy.slug === item.availability_policy">
{{ policy.text }}
</option>
</select>
</div>
<div class="mb-3">
<label for="visibility_policy" class="form-label">Visibility Policy</label>
<select class="form-select" id="visibility_policy" name="visibility_policy"
v-model="item.visibility_policy">
<option v-for="policy in visibility_policies" :value="policy.slug"
:selected="policy.slug === item.visibility_policy">
{{ policy.text }}
</option>
</select>
</div>
<div class="mb-3">
<label for="storage_location" class="form-label">Storage Location</label>
<select class="form-select" id="storage_location" name="storage_location"
v-model="item.storage_location">
<option :value="null">No storage location</option>
<option v-for="location in storage_locations" :value="location.id"
:key="location.id">
{{ location.path }}
</option>
</select>
</div>
<div class="mb-3">
<label for="image" class="form-label">Image</label>
<combined-file-field :item_files="item.files" create
@change="item.files = $event"></combined-file-field>
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary" style="width: 100%"
@click="createInventoryItem(item).then(created => $router.push(ownerOverviewRoute(created)))">Add
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import TagField from "@/components/TagField.vue";
import PropertyField from "@/components/PropertyField.vue";
import CombinedFileField from "@/components/CombinedFileField.vue";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "InventoryNew",
components: {
BaseLayout,
TagField,
PropertyField,
CombinedFileField,
...BIcons
},
props: {
// Matches /inventory/new/:group?. See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
group: {
type: String,
default: null
}
},
data() {
return {
item: {
name: "",
description: "",
owned_quantity: 0,
availability_policy: "",
visibility_policy: "",
image: "",
tags: [],
properties: [],
files: [],
storage_location: null,
// The group's own "#name@domain" handle once picked, or null for a personal
// item - createInventoryItem resolves the target domain from it directly.
owner_group: null
}
}
},
methods: {
ownerOverviewRoute,
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups',
'fetchGroupMemberships'])
},
computed: {
...mapState(["availability_policies", "visibility_policies", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer (see
// Groups.vue's allGroups for the same merge/dedupe).
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))
}
},
async mounted() {
await this.fetchInfo();
await this.fetchStorageLocations();
await this.fetchGroups();
await this.fetchGroupMemberships();
if (this.group) {
this.item.owner_group = decodeHandleFromUrl(this.group)
}
}
}
</script>
<style scoped>
</style>

View file

@ -24,7 +24,9 @@
<label class="form-label">Username</label>
<input class="form-control form-control-lg" type="text"
name="username" placeholder="Enter your username"
:class="errors.username?['is-invalid']:[]"
v-model="username"/>
<div class="invalid-feedback">{{ errors.username }}</div>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
@ -53,6 +55,10 @@
Dont have an account?
<router-link to="/register">Sign up</router-link>
</p>
<p class="mb-0 text-muted">
Connect with existing account?
<router-link to="/pairing">Pairing</router-link>
</p>
</div>
</div>
</div>
@ -65,6 +71,7 @@
</template>
<script>
import {mapActions, mapMutations} from 'vuex';
import router from "@/router";
export default {
@ -74,15 +81,15 @@ export default {
msg: 'Welcome to ' + location.hostname,
username: '',
password: '',
remember: false
remember: false,
errors: {
username: '',
}
}
},
methods: {
setRemember(remember) {
},
login(data) {
return true;
},
...mapActions(['login']),
...mapMutations(['setRemember']),
async do_login(e) {
e.preventDefault();
if (await this.login({username: this.username, password: this.password, remember: this.remember})) {
@ -92,11 +99,24 @@ export default {
await router.push({path: '/'});
}
} else {
if (!this.username.includes('@')) {
this.errors.username = 'Username should be in the form of user@domain';
}
this.msg = 'Invalid username or password';
}
},
},
mounted() {
if (this.$route.query.redirect) {
this.msg = 'You must be logged in to access that page';
}
const user = localStorage.getItem('user');
if (user) {
this.username = user;
}
}
}
</script>

View file

@ -0,0 +1,236 @@
<template>
<div class="main d-flex w-100">
<div class="container d-flex flex-column">
<div class="row vh-100">
<div class="col-sm-10 col-md-8 col-lg-6 mx-auto d-table h-100">
<div class="d-table-cell align-middle">
<div class="text-center mt-4">
<h1 class="h2">
Toolshed
</h1>
<p class="lead" v-if="msg">
{{ msg }}
</p>
<p class="lead" v-else>
Create an account to get started
</p>
</div>
<div class="card">
<div class="card-body">
<div class="m-sm-4">
<div :class="errors.raw_record?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Identity Record</label>
<div class="input-group">
<input class="form-control form-control-lg" type="text"
:class="errors.raw_record?['is-invalid']:[]"
v-model="raw_record" placeholder="Paste identity record"
@input.prevent="try_parsing">
<span class="input-group-text form-control-lg" v-if="raw_record == ''"
@click="pasteClipboard()">
<b-icon-clipboard></b-icon-clipboard>
</span>
<span class="input-group-text form-control-lg" v-else
@click="raw_record = ''">
<b-icon-x-circle></b-icon-x-circle>
</span>
</div>
<div class="invalid-feedback">
{{ errors.raw_record }}
</div>
</div>
<div class="mb-3" v-if="is_unlocked || is_locked">
<label class="form-label">Username</label>
<div class="input-group">
<input class="form-control form-control-lg" type="email"
disabled v-model="username">
<span class="input-group-text form-control-lg bg-success"
v-if="is_unlocked">
<b-icon-unlock></b-icon-unlock>
</span>
<span class="input-group-text form-control-lg bg-warning"
v-if="is_locked">
<b-icon-lock></b-icon-lock>
</span>
</div>
</div>
<div class="mb-3" v-if="is_locked" :class="errors.password?['is-invalid']:[]">
<form role="form" method="post" @submit.prevent="try_unlock">
<label class="form-label">Password</label>
<div class="input-group">
<input class="form-control form-control-lg" type="password"
name="password" placeholder="Enter your password"
v-model="password" :class="errors.password?['is-invalid']:[]">
<button class="btn btn-lg btn-primary" type="button"
@click="try_unlock">
<b-icon-unlock></b-icon-unlock>
unlock
</button>
</div>
<div class="invalid-feedback">
{{ errors.password }}
</div>
</form>
</div>
<div>
<label class="form-check">
<input class="form-check-input" type="checkbox" value="remember-me"
name="remember-me" checked v-model="remember"
@change="setRemember(remember)">
<span class="form-check-label">
Remember me next time
</span>
</label>
</div>
<div class="text-center mt-3">
<button type="submit" class="btn btn-lg btn-primary" :disabled="!is_unlocked"
@click="try_pairing">
Pair
</button>
</div>
<br/>
<div class="text-center">
<p class="mb-0 text-muted">
Already have an account?
<router-link to="/login">Login</router-link>
</p>
<p class="mb-0 text-muted">
Dont have an account?
<router-link to="/register">Sign up</router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapMutations} from 'vuex';
import {ServerSet, createSignAuth} from '../federation';
import NeighborsCache from '../neigbors';
import {decryptIdentityRecord, parseIdentityRecord, serializeIdentityRecord} from "@/identity";
import router from "@/router";
export default {
name: 'Pairing',
data() {
return {
msg: 'Pair with existing Account',
//raw_record: 'test_a@a.localhost:4ab79601edc400fd0263c7d5fd045ea7f5de28f1dd8905e2479f96893f3257d8:7f0ee42b',
//raw_record: 'test_a@a.localhost:$pbkdf2$100000$67da1c2a055dd41fc9707c37d2cdecea$8173b4d626352bcafab626b2$60027066142f55ecfd6f91b587f99c5f02ee536ce08c31e3f69148a51711489220a2b10d5450166ee09a781f891be8ab3a418e31a8e71f6468f189ba0baf31ece21166b63ba608683bdbea8afa7dfc33b1a994d8be4e4b2123',
raw_record: '',
crypted_record: null,
username: '',
password: '',
keypair: '',
remember: false,
errors: {}
}
},
computed: {
is_unlocked() {
return !!this.keypair && !!this.username;
},
is_locked() {
return !!this.username && !!this.crypted_record && !this.keypair;
}
},
methods: {
...mapActions(['lookupServer']),
...mapMutations(['setRemember', 'setUserFromIdentityRecord']),
async try_pairing() {
const unreachable = new NeighborsCache();
const servers = await this.lookupServer({username: this.username}).then(servers => new ServerSet(servers, unreachable))
const auth = createSignAuth(this.username, this.keypair.signSk);
const reply = await servers.getRaw(auth, '/api/v1/friends/');
if (reply.status === 200) {
this.setUserFromIdentityRecord({username: this.username, keypair: this.keypair});
router.push({path: '/'});
} else {
console.error('Error:', reply);
}
},
try_parsing() {
if (!this.raw_record) {
this.username = '';
this.keypair = '';
this.errors = {};
return;
}
const parsed = parseIdentityRecord(this.raw_record)
const {user, keypair, errors} = parsed;
if (user) {
this.username = user;
}
if (errors) {
this.errors.raw_record = errors;
}
if (keypair) {
this.keypair = keypair;
}
if (parsed.crypted) {
this.crypted_record = parsed;
}
},
async try_unlock() {
const {
user,
keypair,
errors
} = await decryptIdentityRecord(this.crypted_record, this.username, this.password);
if (user) {
this.username = user;
}
if (errors) {
this.errors.password = errors;
}
if (keypair) {
this.keypair = keypair;
}
},
async pasteClipboard() {
try {
this.raw_record = await navigator.clipboard.readText();
this.try_parsing()
} catch (e) {
console.error('Error:', e);
}
}
},
mounted() {
try {
fetch('/local/domains')
.then(response => response.json())
.then(data => {
this.domains = data;
});
} catch (e) {
console.error('Error:', e);
}
}
}
</script>
<style scoped>
.is-invalid input, .is-invalid select {
border: 1px solid var(--bs-danger);
}
.is-invalid .invalid-feedback {
display: block;
}
.input-group-text svg {
overflow: visible;
}
</style>

View file

@ -0,0 +1,889 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Print a label</h1>
<!-- Left: what to print. Right: how it looks and how it prints, kept in view via .print-sticky. -->
<div class="row">
<div class="col-lg-7 mb-4">
<div class="print-sticky">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Preview</h5>
<small class="text-muted">{{ previewTape.mediaWidthMm }} mm tape{{
textSizesSummary
}}<span v-if="!tape"> (example)</span></small>
</div>
<div class="card-body">
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="warnings.length" class="alert alert-warning" role="alert">
<div v-for="(w, i) in warnings" :key="i">{{ w.message }}</div>
</div>
<div v-if="!usbSupported" class="alert alert-warning">
This browser can't pair with USB label printers directly. Open this page in
Chrome, Edge or
Opera over https:// for that - you can still build and download labels
as PNGs below.
</div>
<div class="preview-row mb-3" v-show="labelBitmap">
<div class="ruler-v">
<div class="ruler-v-corner"></div>
<div class="ruler ruler-v-ticks"
:style="{height: (previewTape.mediaWidthMm * tapePxPerMm) + 'px'}">
<span v-for="t in verticalRulerTicks" :key="'tick-' + t.mm"
class="tick" :class="{'tick-major': t.major}"
:style="{top: t.pos + 'px'}"></span>
<span v-for="t in verticalRulerTicks.filter(t => t.major)"
:key="'label-' + t.mm" class="tick-label"
:style="{top: t.pos + 'px'}">{{ t.mm }}</span>
</div>
</div>
<div class="preview-track">
<div class="ruler ruler-h"
:style="{width: (printedWidthPx * zoom) + 'px'}">
<span v-for="t in horizontalRulerTicks" :key="'tick-' + t.mm"
class="tick" :class="{'tick-major': t.major}"
:style="{left: t.pos + 'px'}"></span>
<span v-for="t in horizontalRulerTicks.filter(t => t.major)"
:key="'label-' + t.mm" class="tick-label"
:style="{left: t.pos + 'px'}">{{ t.mm }}</span>
</div>
<!-- Tape's full physical width; canvas is narrower/centered, margin is real tape. See docs/implementation.md#tape-full-print-margin. -->
<div class="tape-full"
:style="{height: (previewTape.mediaWidthMm * tapePxPerMm) + 'px'}">
<div class="label-preview">
<canvas ref="labelCanvas"></canvas>
</div>
</div>
</div>
</div>
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label">Orientation</label>
<div class="btn-group btn-group-toggle" role="group">
<label class="btn btn-outline-secondary"
:class="{active: orientation === 'along'}">
<input type="radio" autocomplete="off" value="along"
v-model="orientation"> Along tape
</label>
<label class="btn btn-outline-secondary"
:class="{active: orientation === 'across'}">
<input type="radio" autocomplete="off" value="across"
v-model="orientation"> Across tape
</label>
</div>
</div>
<div class="col-auto">
<label class="form-label">Copies</label>
<input type="number" class="form-control copies-input"
v-model.number="copies" min="1" max="20" :disabled="!connected">
</div>
<div class="col-auto" v-if="chainSupported">
<label class="form-label d-block">Chain mode</label>
<div class="custom-control custom-switch">
<input type="checkbox" class="custom-control-input"
id="chain-mode" v-model="chainMode">
<label class="custom-control-label" for="chain-mode"></label>
</div>
</div>
<div class="col-auto" v-if="!connected">
<label class="form-label">Tape height (px)</label>
<input type="number" class="form-control copies-input"
v-model.number="simulatedHeightPx" min="8" max="1000">
</div>
<div class="col-auto ms-auto">
<button class="btn btn-primary" :disabled="!canAct"
@click="connected ? print() : downloadPng()">
<b-icon-printer v-if="connected" class="me-1"></b-icon-printer>
<b-icon-download v-else class="me-1"></b-icon-download>
{{ connected ? "Print" : "Download PNG" }}
</button>
</div>
</div>
<div
class="printer-bar d-flex flex-wrap align-items-center chip-row mt-3 pt-3 border-top">
<span v-for="d in deviceRows" :key="d.index" class="printer-chip"
:class="{'printer-chip-connected': d.isConnected, 'printer-chip-disabled': !d.supported}"
:title="!d.supported ? d.reason : ''">
{{ d.name }}
<button v-if="d.isConnected && !d.isPngExport"
class="btn btn-link btn-sm p-0 ms-1"
:disabled="busy" @click="disconnect()">disconnect</button>
<button v-else-if="!d.isConnected && d.supported"
class="btn btn-link btn-sm p-0 ms-1"
:disabled="busy" @click="connect(d.index)">connect</button>
</span>
<button v-if="usbSupported" class="btn btn-sm btn-outline-primary ms-auto"
:disabled="busy || !blobReady" @click="requestPrinter">
<b-icon-plug class="me-1"></b-icon-plug>
Pair new printer
</button>
</div>
<details class="cli-details mt-3 pt-3 border-top">
<summary class="small text-muted">Print from the command line instead</summary>
<div class="mt-2">
<p class="mb-1"><strong>Brother QL-series</strong></p>
<pre class="code-block"><code>{{ brotherCliExample }}</code></pre>
<p class="mb-1"><strong>Niimbot</strong></p>
<pre class="code-block"><code>{{ niimbotCliExample }}</code></pre>
</div>
</details>
</div>
</div>
</div>
</div>
<div class="col-lg-5 mb-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Content</h5>
</div>
<div class="card-body">
<div class="row g-3">
<div :class="v === 'text' ? 'col-12' : 'col-sm-6'" v-for="v in baseVars" :key="v">
<label class="form-label">{{ varLabel(v) }}</label>
<textarea v-if="v === 'text'" class="form-control" rows="3"
v-model="varValues[v]"
placeholder="https://example.com/…"></textarea>
<select v-else-if="v === 'kind'" class="form-control"
v-model="varValues[v]">
<option value=""></option>
<option v-for="k in contentKinds" :key="k.id" :value="k.id">{{
k.label
}}</option>
</select>
<input v-else type="text" class="form-control" v-model="varValues[v]">
</div>
</div>
<div class="calculated-fields mt-3 pt-3 border-top">
<div class="small text-uppercase text-muted mb-2">Calculated</div>
<div class="d-flex flex-wrap chip-row">
<div v-for="v in derivedVars" :key="v" class="calculated-field">
<div class="small text-muted">{{ varLabel(v) }}</div>
<div class="text-break">{{ fields[v] || "—" }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-12 mb-4">
<label-layout-preview :fields="fields" :value="selectedTemplate" :tape="previewTape"
:recent-template-ids="recentTemplateIds" class="mb-3"
@input="selectedTemplate = $event"></label-layout-preview>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
import {markRaw, nextTick} from "vue";
import {mapActions, mapGetters, mapState} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import LabelLayoutPreview from "@/components/LabelLayoutPreview.vue";
import printerManager from "@/printerManager.js";
import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/weblabel.js";
import {
tapeFromStatus,
drawLabel,
buildLabelContent,
buildLabelFields,
preloadQrEncoder,
preloadPixelFontRenderer,
withDerivedVars,
templateContent
} from "@/label.js";
import {LABEL_TEMPLATES, BASE_VARS, DERIVED_VARS, CONTENT_KINDS, CONTENT_KINDS_BY_ID} from "@/label-layouts.js";
import {shortenedRoute} from "@/router";
// Print.vue-local calculated fields on top of label-layouts.js's DERIVED_VARS. See docs/implementation.md#calculated-short-link-fields.
const SHORT_URL_VAR = "shortUrl";
// See docs/implementation.md#calculated-short-link-fields.
const SHORT_ID_VAR = "shortId";
// See docs/implementation.md#calculated-short-link-fields.
const DOMAIN_SHORT_ID_VAR = "domainShortId";
// Served unbundled so its wasm sibling stays resolvable. See docs/implementation.md#libweblabel-served-unbundled.
const BLOB_URL = "/vendor/libweblabel.js";
// localStorage key for the most-recently-printed template ids (see rememberPrintedTemplate/loadRecentTemplateIds), same naming style as cameraManager.js's recentCameraIds.
const RECENT_TEMPLATES_KEY = "recentLabelTemplateIds";
// How many recently-printed templates LabelLayoutPreview.vue bubbles to the front of the grid.
const MAX_RECENT_TEMPLATES = 4;
const MAX_ZOOM = 4; /* never magnify the preview more than this */
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
// A plausible printer profile for the preview/ruler when the PNG Export "printer" (see deviceRows) is the active one; its height is user-adjustable (see simulatedHeightPx), everything else about it isn't.
const SIMULATED_DPI = 180;
const SIMULATED_DEFAULT_HEIGHT_PX = 72; /* ~9mm at SIMULATED_DPI */
const SIMULATED_MIN_HEIGHT_PX = 8;
const SIMULATED_MAX_HEIGHT_PX = 1000;
const SIMULATED_LEAD_PX = Math.round(2 * SIMULATED_DPI / 25.4); /* blank runway before content, same idea as tapeFromStatus's marginsMm */
// deviceRows' sentinel index for the always-present PNG Export row - never a real index into `devices`.
const PNG_EXPORT_INDEX = -1;
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
const RULER_TIERS = [
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
{aboveMm: 100, tickMm: 1, majorEveryMm: 10},
{aboveMm: 500, tickMm: 5, majorEveryMm: 25},
];
export default {
name: "Print",
components: {
BaseLayout,
LabelLayoutPreview,
...BIcons
},
props: {
// {kind, components} from the ?kind=& query params (router.js's /print route builds this prop); buildLabelContent turns it into the text field below.
prefill: {
type: Object,
default: null
},
// Every query param other than `kind` (router.js), so any base var - e.g. ?text=... - can
// be set directly without needing a `kind` builder in label-layouts.js.
queryFields: {
type: Object,
default: () => ({})
}
},
data() {
return {
// Whether this browser can pair with real USB printers at all; when false, deviceRows below only ever has its PNG Export row.
usbSupported: "usb" in navigator,
blobReady: false,
busy: false,
error: null,
devices: [],
connected: null,
tape: null,
labelBitmap: null,
// Tracked reactively (not read off the canvas) so the mm ruler can recompute tick positions when either changes (see fitZoom/redraw).
zoom: 1,
printedWidthPx: 0,
// Each "text" leaf's effective font size (see label.js's drawLabel), shown beside the tape width so a too-small-to-render field reads as a suspiciously tiny number rather than silently absent.
textSizesPx: [],
// Scan-reliability messages from label.js's drawLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
warnings: [],
// One input per BASE_VARS entry; derived vars (userHandle/url/qualifiedHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over buildLabelFields' computed value.
varValues: {
...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
text: buildLabelContent(this.prefill),
// Defaults to this page's own origin; editable since any frontend can resolve any handle, so a label needn't point back at this one.
webdomain: window.location.origin,
...buildLabelFields(this.prefill),
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
},
copies: 1,
// Only sent to a real printer whose driver reports the "chain" capability (see chainSupported) - harmless to leave set when switching to a driver that ignores it.
chainMode: false,
// PNG Export's stand-in for a real tape's mediaWidthMm - there's no physical tape to measure, so this is the one thing about previewTape's simulated profile the user can adjust.
simulatedHeightPx: SIMULATED_DEFAULT_HEIGHT_PX,
selectedTemplate: LABEL_TEMPLATES[0].id,
// Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
recentTemplateIds: [],
// "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel.
orientation: "along",
// TODO: replace with the real commands for our printers.
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
};
},
computed: {
...mapState(["user"]),
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
homeDomain() {
const at = this.user ? this.user.indexOf("@") : -1;
return at === -1 ? null : this.user.slice(at + 1);
},
baseVars() {
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR && v !== DOMAIN_SHORT_ID_VAR);
},
contentKinds() {
return CONTENT_KINDS;
},
derivedVars() {
return [...Object.keys(DERIVED_VARS), SHORT_ID_VAR, DOMAIN_SHORT_ID_VAR, SHORT_URL_VAR];
},
// Named content fields the templates draw from, dropping blank values. See docs/implementation.md#fields-computed-dropping-blank-values.
fields() {
const base = {};
for (const v of BASE_VARS) {
if (this.varValues[v]) {
base[v] = this.varValues[v];
}
}
const derived = withDerivedVars(base);
const shortId = this.shortId(derived);
if (shortId) {
derived[SHORT_ID_VAR] = shortId;
// Bare, same Home-Instance trust model as shortId - webdomain only picks a frontend, not a backend. See docs/implementation.md#calculated-short-link-fields.
if (derived.webdomain) {
derived[SHORT_URL_VAR] = derived.webdomain + "/" + shortId;
}
if (this.homeDomain) {
derived[DOMAIN_SHORT_ID_VAR] = this.homeDomain + ":" + shortId;
}
}
return derived;
},
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
},
// Always includes the virtual PNG Export row, so the printer list (and the preview built from
// whichever row is active) looks the same whether or not this browser/session has any real
// USB printer paired - PNG Export is simply the active row whenever no real device is.
deviceRows() {
const pngRow = {
index: PNG_EXPORT_INDEX,
name: "PNG Export",
supported: true,
reason: "",
isConnected: !this.connected,
isPngExport: true,
};
if (!this.blob) {
return [pngRow];
}
const real = this.devices.map((d, index) => {
const info = this.blob.findDevice(d.vendorId, d.productId);
return {
index,
name: info ? info.name : (d.productName || "Unknown device"),
supported: Boolean(info && info.supported),
reason: info ? (info.unsupportedReason || "unsupported") : "no driver for this device",
isConnected: d === this.connected,
};
});
return [pngRow, ...real];
},
// Print/Download button and Copies input are only meaningful with a real printer connected; PNG Export can always export once a bitmap's rendered.
canAct() {
return Boolean(this.labelBitmap && !this.busy);
},
// Whether the connected printer's driver honors chain mode - some (e.g. Brother's QL line) don't. See vendor/weblabel.js's MultiPrinterBlob.can.
chainSupported() {
return Boolean(this.connected && this.blob && this.blob.can("chain", this.connected));
},
// The connected printer's tape, or PNG Export's stand-in profile (its height user-adjustable via simulatedHeightPx) so the ruler/preview always have something realistic to show.
previewTape() {
if (this.tape) {
return this.tape;
}
const printAreaPx = Math.min(SIMULATED_MAX_HEIGHT_PX,
Math.max(SIMULATED_MIN_HEIGHT_PX, Number(this.simulatedHeightPx) || SIMULATED_DEFAULT_HEIGHT_PX));
return {
dpi: SIMULATED_DPI,
mediaWidthMm: Math.round(printAreaPx / SIMULATED_DPI * 25.4 * 10) / 10,
printAreaPx,
printLengthPx: 0,
leadPx: SIMULATED_LEAD_PX,
};
},
// px per real mm at current zoom; meaningful only for the tape-fed preview (the no-webusb fallback has no real tape/dpi, so no ruler).
tapePxPerMm() {
return (this.previewTape.dpi / 25.4) * this.zoom;
},
// Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
horizontalTotalMm() {
return this.printedWidthPx ? this.printedWidthPx / (this.previewTape.dpi / 25.4) : 0;
},
verticalTotalMm() {
return this.previewTape.mediaWidthMm;
},
// Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
rulerTier() {
return RULER_TIERS.filter(t => Math.max(this.horizontalTotalMm, this.verticalTotalMm) >= t.aboveMm)
.at(-1);
},
// Ticks along the tape's printed length, feed margins included (still real tape).
horizontalRulerTicks() {
if (!this.printedWidthPx) {
return [];
}
return this.rulerTicks(this.horizontalTotalMm);
},
// Ticks across the tape's full width, not just the printable area. See docs/implementation.md#tape-full-print-margin.
verticalRulerTicks() {
return this.rulerTicks(this.verticalTotalMm);
},
// e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width <small> with nothing shown.
textSizesSummary() {
if (!this.textSizesPx.length) {
return "";
}
return ` (${this.textSizesPx.map(px => Math.round(px) + "px").join(", ")})`;
},
},
watch: {
varValues: {
handler() {
this.redraw();
},
deep: true,
},
selectedTemplate() {
this.redraw();
},
orientation() {
this.redraw();
},
simulatedHeightPx() {
this.redraw();
},
// Catches printer connect/disconnect/switch, redrawing with the real tape or falling back to previewTape's simulated one.
tape: {
handler() {
this.redraw();
},
flush: 'post'
},
},
methods: {
...mapActions(["fetchIdMap"]),
// camelCase -> Title Case (e.g. "qualifiedHandle" -> "Qualified Handle") so a new template var needs no hand-written label.
varLabel(v) {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
},
// Which short-id.js kind applies depends on f.kind directly. See docs/implementation.md#content-kinds-registry.
shortId(f) {
const entry = f.kind && CONTENT_KINDS_BY_ID[f.kind];
if (!f.userHandle || !entry || !f.id) {
return null;
}
if (f.userHandle.startsWith("#")) {
const owner_group_id = this.groupIdByHandle[f.userHandle];
if (owner_group_id === undefined) {
return null;
}
return shortenedRoute({
kind: entry.groupShortIdKind, owner_group_id, [entry.localIdField]: f.id
}).slice(1);
}
const owner_identity_id = this.identityIdByHandle[f.userHandle];
if (owner_identity_id === undefined) {
return null;
}
return shortenedRoute({
kind: entry.shortIdKind, owner_identity_id, [entry.localIdField]: f.id
}).slice(1);
},
// Ticks 0..totalMm via tapePxPerMm, shared by both ruler computeds; spacing comes from the shared rulerTier, so both rulers coarsen together.
rulerTicks(totalMm) {
const {tickMm, majorEveryMm} = this.rulerTier;
const ticks = [];
for (let mm = 0; mm <= totalMm; mm += tickMm) {
ticks.push({mm, pos: mm * this.tapePxPerMm, major: mm % majorEveryMm === 0});
}
return ticks;
},
loadRecentTemplateIds() {
try {
const saved = localStorage.getItem(RECENT_TEMPLATES_KEY);
return saved ? JSON.parse(saved) : [];
} catch (e) {
return [];
}
},
rememberPrintedTemplate(id) {
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
this.recentTemplateIds = updated;
try {
localStorage.setItem(RECENT_TEMPLATES_KEY, JSON.stringify(updated));
} catch (e) {
// Best-effort only - a disabled/full localStorage shouldn't block printing.
}
},
async guard(fn) {
this.error = null;
this.busy = true;
try {
await fn();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async refreshDevices() {
this.devices = (await printerManager.enumerateDevices()).map((d) => markRaw(d));
/* A printer unplugged while open drops from the list: its handle is gone, so close the card rather than keep it open on a dead connection. */
if (this.connected !== null && !this.devices.includes(this.connected)) {
this.connected = null;
this.tape = null;
}
},
requestPrinter() {
this.guard(async () => {
try {
await navigator.usb.requestDevice({filters: this.blob.usbFilters()});
} catch (e) {
if (e.name === "NotFoundError") {
return;
}
throw e;
}
await this.refreshDevices();
});
},
async closeConnection() {
if (this.connected) {
await this.blob.close();
this.connected = null;
this.tape = null;
}
},
// Only one open connection at a time: connecting a different printer disconnects the current one first.
async connectToDevice(device) {
await this.closeConnection();
this.blob.setDevices([device]);
await this.blob.open(device.vendorId, device.productId);
this.connected = device;
printerManager.savePreferredPrinter(device);
const status = await this.blob.status();
// Setting `tape` alone is enough to redraw - see the tape watcher above.
this.tape = this.blob.can("print") ? tapeFromStatus(status) : null;
},
connect(index) {
if (index === PNG_EXPORT_INDEX) {
this.guard(() => this.closeConnection());
return;
}
this.guard(() => this.connectToDevice(this.devices[index]));
},
// Auto-connects to the most-recently-used printer once it's paired and visible again (page
// load, or plugged back in - see onUsbChange), mirroring cameraManager's preferred-camera
// auto-select. Gated on "nothing connected yet" since a manual connect() to a different
// paired printer shouldn't be silently overridden by a later device-list refresh.
autoConnectPreferred() {
if (this.connected) {
return Promise.resolve();
}
const preferred = printerManager.findPreferredDevice(this.devices);
return preferred ? this.connectToDevice(preferred) : Promise.resolve();
},
disconnect() {
this.guard(() => this.closeConnection());
},
redraw() {
this.labelBitmap = null;
const canvas = this.$refs.labelCanvas;
if (!canvas) {
return;
}
this.resizeObserver.observe(canvas.parentElement);
let textSizesPx, warnings;
try {
const content = templateContent(this.currentTemplate, this.fields);
({textSizesPx, warnings} = drawLabel(canvas, this.previewTape, content, this.orientation));
} catch (e) {
this.error = e.message;
return;
}
this.error = null;
this.textSizesPx = textSizesPx;
this.warnings = warnings;
const bitmap = canvasToBitmap(canvas);
bitmapToCanvas(canvas, bitmap);
this.labelBitmap = bitmap;
this.printedWidthPx = canvas.width;
this.zoom = this.fitZoom(canvas);
},
downloadPng() {
const canvas = this.$refs.labelCanvas;
if (!canvas) {
return;
}
const link = document.createElement("a");
link.download = "label.png";
link.href = canvas.toDataURL("image/png");
link.click();
this.rememberPrintedTemplate(this.selectedTemplate);
},
// Fits the preview to its card, magnifying up to MAX_ZOOM/MAX_PREVIEW_HEIGHT_PX. See docs/implementation.md#fit-zoom-preview-scaling.
fitZoom(canvas) {
const available = canvas.parentElement.clientWidth;
if (!(available > 0)) {
return 1;
}
const rawZoom = Math.min(MAX_ZOOM, available / canvas.width, MAX_PREVIEW_HEIGHT_PX / canvas.height);
// Floors rather than rounds/ceils when magnifying, to keep image-rendering:pixelated crisp. See docs/implementation.md#fit-zoom-preview-scaling.
const zoom = rawZoom >= 1 ? Math.max(1, Math.floor(rawZoom)) : rawZoom;
canvas.style.width = `${canvas.width * zoom}px`;
canvas.style.height = `${canvas.height * zoom}px`;
canvas.style.imageRendering = zoom >= 1 ? "pixelated" : "auto";
return zoom;
},
print() {
this.guard(async () => {
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
await this.blob.printBitmap(this.labelBitmap, {copies, chain: this.chainMode});
this.rememberPrintedTemplate(this.selectedTemplate);
});
},
// Re-fits the resized container's canvas (window resize, sidebar toggle, font load, etc.); debounced since ResizeObserver can fire in bursts.
handleContainerResize(entries) {
clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
for (const entry of entries) {
const canvas = entry.target.querySelector("canvas");
if (canvas) {
this.zoom = this.fitZoom(canvas);
}
}
}, 100);
},
onUsbChange() {
this.guard(async () => {
await this.refreshDevices();
await this.autoConnectPreferred();
});
},
},
created() {
this.blob = null;
this.resizeObserver = null;
this.resizeTimer = null;
this.recentTemplateIds = this.loadRecentTemplateIds();
},
async mounted() {
// Not awaited: resolving late just means shortUrl briefly shows "" instead of blocking the unrelated printer/wasm setup below.
this.fetchIdMap().catch(e => {
this.error = e.message;
});
this.resizeObserver = new ResizeObserver(this.handleContainerResize);
// Loaded concurrently with MultiPrinterBlob below rather than serialized. See docs/implementation.md#concurrent-wasm-loading.
const qrReady = preloadQrEncoder();
// Same reasoning - drawTextLeaf's bitmap-font tiers (see label.js's fontTierFor) need this
// loaded before the first real draw, same as qrReady below.
const fontsReady = Promise.all([qrReady, preloadPixelFontRenderer()]);
const blobLoad = this.usbSupported
? MultiPrinterBlob.load(BLOB_URL).catch(e => {
this.error = `Could not load the printer driver: ${e.message}`;
return null;
})
: Promise.resolve(null);
await fontsReady;
// Draws PNG Export's simulated tape right away rather than waiting on the driver/device enumeration below; the tape watcher redraws again with a real one if/once autoConnectPreferred finds it.
await nextTick();
this.redraw();
this.blob = await blobLoad;
if (!this.blob) {
return;
}
this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
await this.guard(async () => {
await this.refreshDevices();
await this.autoConnectPreferred();
});
},
beforeUnmount() {
if ("usb" in navigator) {
navigator.usb.removeEventListener("connect", this.onUsbChange);
navigator.usb.removeEventListener("disconnect", this.onUsbChange);
}
this.resizeObserver.disconnect();
clearTimeout(this.resizeTimer);
},
}
</script>
<style scoped>
/* Sticks the preview/print card in view while the left column scrolls; only above `lg`, where the columns are actually side by side. */
@media (min-width: 992px) {
.print-sticky {
position: sticky;
top: 1rem;
}
}
.chip-row {
gap: .5rem;
}
.calculated-field {
min-width: 6rem;
}
.printer-chip {
display: inline-flex;
align-items: center;
border: 1px solid var(--bs-gray-300);
border-radius: 1rem;
padding: .15rem .35rem .15rem .7rem;
font-size: .85rem;
}
.printer-chip-connected {
border-color: var(--bs-success);
color: var(--bs-success);
}
.printer-chip-disabled {
color: var(--bs-gray-600);
}
.cli-details summary {
cursor: pointer;
}
.label-preview {
overflow-x: auto;
padding: .75rem;
//background: rgba(127, 127, 127, .08);
border-radius: .35rem;
text-align: center;
max-height: 300px;
}
.label-preview canvas {
display: inline-block;
background: #fff;
//box-shadow: 0 0 0 1px rgba(127, 127, 127, .5);
}
/* The tape-fed preview's mm ruler: horizontal track above canvas, vertical to its left, both ticked in real physical mm. See docs/implementation.md#mm-ruler-layout. */
.preview-row {
display: flex;
align-items: flex-start;
}
/* Ruler/canvas container deliberately never scrollable. See docs/implementation.md#preview-track-no-scroll-invariant. */
.preview-track {
flex: 1 1 auto;
min-width: 0;
}
/* Nested override: .label-preview must neither scroll nor center its canvas here. See docs/implementation.md#label-preview-override-in-preview-track. */
.preview-track .label-preview {
overflow-x: visible;
text-align: left;
padding: 0;
}
/* Tape's full physical width; canvas is narrower/centered, faint tint marks the real unprintable margin. See docs/implementation.md#tape-full-print-margin. */
.tape-full {
display: flex;
flex-direction: column;
justify-content: center;
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
}
.ruler-v {
flex: none;
width: 2.2rem;
color: rgba(127, 127, 127, .9);
}
/* Matches .ruler-h's height so the vertical ruler's tick 0 lines up with the canvas's top edge. */
.ruler-v-corner {
height: 1.6rem;
}
.ruler-v-ticks {
position: relative;
}
.ruler-h {
position: relative;
height: 1.6rem;
color: rgba(127, 127, 127, .9);
}
.ruler .tick {
position: absolute;
background: currentColor;
}
/* Ticks anchor to the edge nearest the canvas and grow outward (pointing at the label); mm labels sit on the opposite outer edge. */
.ruler-v-ticks .tick {
right: 0;
width: .4rem;
height: 1px;
}
.ruler-v-ticks .tick-major {
width: .7rem;
}
.ruler-h .tick {
bottom: 0;
width: 1px;
height: .4rem;
}
.ruler-h .tick-major {
height: .7rem;
}
.tick-label {
position: absolute;
font-size: .6rem;
line-height: 1;
white-space: nowrap;
}
/* transform (not a fixed em nudge) centers the label on the tick's mm position regardless of text size. */
.ruler-v-ticks .tick-label {
left: 0;
transform: translateY(-50%);
}
.ruler-h .tick-label {
top: 0;
transform: translateX(-50%);
}
.copies-input {
width: 5.5rem;
}
.code-block {
background: rgba(127, 127, 127, .08);
border-radius: .35rem;
padding: .75rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View file

@ -0,0 +1,267 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Profile</h1>
<div class="row">
<div class="col-md-4 col-xl-3">
<div class="card mb-3">
<div class="card-header">
<h5 class="card-title mb-0">Profile Details</h5>
</div>
<div class="card-body text-center">
<authenticated-image :src="profilePictureSrc" :owner="userHandle"
:alt="profile.username || userHandle"
:title="profile.username || userHandle"
img-class="img-fluid rounded-circle mb-2"
:width="128" :height="128" fit="cover"/>
<h5 class="card-title mb-0">
{{ profile.username }}
</h5>
<div class="text-muted mb-2">
{{ profile.email }}
</div>
<div>
<a class="btn btn-primary btn-sm" href="#">Follow</a>
<a class="btn btn-primary btn-sm" href="#"><span
data-feather="message-square"></span> Message</a>
</div>
</div>
<!--{% if profile.bio %}-->
<hr class="my-0"/>
<div class="card-body">
<h5 class="h6 card-title">Bio</h5>
<div class="text-muted mb-2">
{{ profile.bio }}
</div>
</div>
<!--{% endif %}-->
<hr class="my-0"/>
<div class="card-body">
<h5 class="h6 card-title">Skills</h5>
<a href="#" class="badge bg-primary mr-1 my-1">HTML</a>
<a href="#" class="badge bg-primary mr-1 my-1">JavaScript</a>
<a href="#" class="badge bg-primary mr-1 my-1">Sass</a>
<a href="#" class="badge bg-primary mr-1 my-1">Angular</a>
<a href="#" class="badge bg-primary mr-1 my-1">Vue</a>
<a href="#" class="badge bg-primary mr-1 my-1">React</a>
<a href="#" class="badge bg-primary mr-1 my-1">Redux</a>
<a href="#" class="badge bg-primary mr-1 my-1">UI</a>
<a href="#" class="badge bg-primary mr-1 my-1">UX</a>
</div>
<hr class="my-0"/>
<div class="card-body">
<h5 class="h6 card-title">About</h5>
<ul class="list-unstyled mb-0">
<!--{% if profile.location %}-->
<li class="mb-1"><span data-feather="home" class="feather-sm mr-1"></span> Lives
in <a href="#">{{ profile.location }}</a></li>
<!--{% endif %}-->
<li class="mb-1"><span data-feather="briefcase" class="feather-sm mr-1"></span>
Works at <a href="#">GitHub</a></li>
<li class="mb-1"><span data-feather="map-pin" class="feather-sm mr-1"></span>
From <a href="#">Boston</a></li>
</ul>
</div>
<hr class="my-0"/>
<div class="card-body">
<h5 class="h6 card-title">Elsewhere</h5>
<ul class="list-unstyled mb-0">
<li class="mb-1"><span class="fas fa-globe fa-fw mr-1"></span> <a href="#">staciehall.co</a>
</li>
<li class="mb-1"><span class="fab fa-twitter fa-fw mr-1"></span> <a
href="#">Twitter</a>
</li>
<li class="mb-1"><span class="fab fa-facebook fa-fw mr-1"></span> <a href="#">Facebook</a>
</li>
<li class="mb-1"><span class="fab fa-instagram fa-fw mr-1"></span> <a href="#">Instagram</a>
</li>
<li class="mb-1"><span class="fab fa-linkedin fa-fw mr-1"></span> <a href="#">LinkedIn</a>
</li>
</ul>
</div>
</div>
</div>
<div class="col-md-8 col-xl-9">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Activities</h5>
</div>
<div class="card-body h-100">
<div class="d-flex align-items-start">
<img src="/assets/img/avatars/avatar-5.png" width="36" height="36"
class="rounded-circle mr-2" alt="Vanessa Tucker">
<div class="flex-grow-1">
<small class="float-right text-navy">5m ago</small>
<strong>Vanessa Tucker</strong> started following <strong>Christina
Mason</strong><br/>
<small class="text-muted">Today 7:51 pm</small><br/>
</div>
</div>
<hr/>
<div class="d-flex align-items-start">
<img src="/assets/img/avatars/avatar.png" width="36" height="36"
class="rounded-circle mr-2" alt="Charles Hall">
<div class="flex-grow-1">
<small class="float-right text-navy">30m ago</small>
<strong>Charles Hall</strong> posted something on <strong>Christina
Mason</strong>'s timeline<br/>
<small class="text-muted">Today 7:21 pm</small>
<div class="border text-sm text-muted p-2 mt-1">
Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem
quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam
nunc, blandit vel, luctus
pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt
tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis
ante.
</div>
<a href="#" class="btn btn-sm btn-danger mt-1"><i class="feather-sm"
data-feather="heart"></i>
Like</a>
</div>
</div>
<hr/>
<div class="d-flex align-items-start">
<img src="/assets/img/avatars/avatar-4.png" width="36" height="36"
class="rounded-circle mr-2" alt="Christina Mason">
<div class="flex-grow-1">
<small class="float-right text-navy">1h ago</small>
<strong>Christina Mason</strong> posted a new blog<br/>
<small class="text-muted">Today 6:35 pm</small>
</div>
</div>
<hr/>
<div class="d-flex align-items-start">
<img src="/assets/img/avatars/avatar-2.png" width="36" height="36"
class="rounded-circle mr-2" alt="William Harris">
<div class="flex-grow-1">
<small class="float-right text-navy">3h ago</small>
<strong>William Harris</strong> posted two photos on <strong>Christina
Mason</strong>'s timeline<br/>
<small class="text-muted">Today 5:12 pm</small>
<div class="row g-0 mt-1">
<div class="col-6 col-md-4 col-lg-4 col-xl-3">
<img src="/assets/img/photos/unsplash-1.jpg"
class="img-fluid pr-2" alt="Unsplash">
</div>
<div class="col-6 col-md-4 col-lg-4 col-xl-3">
<img src="/assets/img/photos/unsplash-2.jpg"
class="img-fluid pr-2" alt="Unsplash">
</div>
</div>
<a href="#" class="btn btn-sm btn-danger mt-1"><i class="feather-sm"
data-feather="heart"></i>
Like</a>
</div>
</div>
<hr/>
<div class="d-flex align-items-start">
<img src="/assets/img/avatars/avatar-2.png" width="36" height="36"
class="rounded-circle mr-2" alt="William Harris">
<div class="flex-grow-1">
<small class="float-right text-navy">1d ago</small>
<strong>William Harris</strong> started following <strong>Christina
Mason</strong><br/>
<small class="text-muted">Yesterday 3:12 pm</small>
<div class="d-flex align-items-start mt-1">
<a class="pr-3" href="#">
<img src="/assets/img/avatars/avatar-4.png" width="36"
height="36" class="rounded-circle mr-2" alt="Christina Mason">
</a>
<div class="flex-grow-1">
<div class="border text-sm text-muted p-2 mt-1">
Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id,
lorem. Maecenas nec odio et ante tincidunt tempus.
</div>
</div>
</div>
</div>
</div>
<hr/>
<div class="d-flex align-items-start">
<img src="/assets/img/avatars/avatar-4.png" width="36" height="36"
class="rounded-circle mr-2" alt="Christina Mason">
<div class="flex-grow-1">
<small class="float-right text-navy">1d ago</small>
<strong>Christina Mason</strong> posted a new blog<br/>
<small class="text-muted">Yesterday 2:43 pm</small>
</div>
</div>
<hr/>
<div class="d-flex align-items-start">
<img src="/assets/img/avatars/avatar.png" width="36" height="36"
class="rounded-circle mr-2" alt="Charles Hall">
<div class="flex-grow-1">
<small class="float-right text-navy">1d ago</small>
<strong>Charles Hall</strong> started following <strong>Christina
Mason</strong><br/>
<small class="text-muted">Yesterdag 1:51 pm</small>
</div>
</div>
<hr/>
<a href="#" class="btn btn-primary btn-block">Load more</a>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import {mapActions, mapState} from "vuex";
export default {
name: 'Profile',
components: {BaseLayout, AuthenticatedImage},
computed: {
...mapState(['user', 'user_profile']),
userHandle() {
return this.user;
},
profile() {
return this.user_profile || {
username: this.user,
email: ''
};
},
profilePictureSrc() {
return this.user_profile?.profile_picture?.name || null;
}
},
methods: {
...mapActions(['fetchUserProfile'])
},
mounted() {
this.fetchUserProfile();
}
}
</script>
<style scoped>
</style>

View file

@ -27,9 +27,7 @@
type="text" v-model="form.username" id="validationCustomUsername"
placeholder="Enter your username" required/>
<div class="input-group-prepend">
<span class="input-group-text form-control form-control-lg">@</span>
</div>
<span class="input-group-text form-control-lg">@</span>
<select class="form-control form-control-lg"
id="exampleFormControlSelect1"
placeholder="Domain" v-model="form.domain" required>
@ -44,13 +42,19 @@
<div :class="errors.email?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Email</label>
<input class="form-control form-control-lg" type="email"
:class="errors.email?['is-invalid']:[]"
v-model="form.email" placeholder="Enter your email"/>
<div class="invalid-feedback">{{ errors.email }}</div>
</div>
<input type="text" class="visually-hidden" autocomplete="username"
tabindex="-1" aria-hidden="true"
v-model="fullHandle"/>
<div :class="errors.password?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Password</label>
<input class="form-control form-control-lg" type="password"
:class="errors.password?['is-invalid']:[]"
v-model="form.password" placeholder="Enter your password"/>
<div class="invalid-feedback">{{ errors.password }}</div>
</div>
@ -58,6 +62,7 @@
<div :class="errors.password2?['mb-3','is-invalid']:['mb-3']">
<label class="form-label">Password Check</label>
<input class="form-control form-control-lg" type="password"
:class="errors.password2?['is-invalid']:[]"
v-model="password2" placeholder="Enter your password again"/>
<div class="invalid-feedback">{{ errors.password2 }}</div>
</div>
@ -74,6 +79,10 @@
Already have an account?
<router-link to="/login">Login</router-link>
</p>
<p class="mb-0 text-muted">
Connect with existing account?
<router-link to="/pairing">Pairing</router-link>
</p>
</div>
</div>
</div>
@ -86,6 +95,10 @@
</template>
<script>
import {mapActions} from 'vuex';
import {ServerSet, createNullAuth} from '../federation';
import NeighborsCache from '../neigbors';
export default {
name: 'Register',
data() {
@ -108,7 +121,39 @@ export default {
domains: []
}
},
computed: {
fullHandle: {
get() {
return this.form.domain ? `${this.form.username}@${this.form.domain}` : this.form.username;
},
set(value) {
this.applyHandle(value);
}
}
},
watch: {
// Catches the same "user@domain" shape when it's typed or pasted
// directly into the visible username field instead.
'form.username'(value) {
if (value.includes('@')) {
this.applyHandle(value);
}
}
},
methods: {
...mapActions(['lookupServer']),
applyHandle(value) {
const atIndex = value.indexOf('@');
if (atIndex === -1) {
this.form.username = value;
return;
}
this.form.username = value.slice(0, atIndex);
const domain = value.slice(atIndex + 1);
if (this.domains.includes(domain)) {
this.form.domain = domain;
}
},
do_register() {
console.log('do_register');
console.log(this.form);
@ -118,36 +163,34 @@ export default {
} else {
this.errors.password2 = null;
}
fetch('/auth/register/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.form)
})
.then(response => response.json())
.then(data => {
if (data.errors) {
console.error('Error:', data.errors);
this.errors = data.errors;
const user = this.form.username + '@' + this.form.domain;
var unreachable = new NeighborsCache();
var nullAuth = createNullAuth({})
this.lookupServer({username: user}).then(servers => new ServerSet(servers, unreachable))
.then(set => set.post(nullAuth, '/auth/register/', this.form))
.then(response => {
if (response.errors) {
console.error('Error:', response.errors);
this.errors = response.errors;
return;
}
console.log('Success:', data);
console.log('Success:', response);
this.msg = 'Success';
localStorage.setItem('user', user);
this.$router.push('/login');
})
.catch((error) => {
console.error('Error:', error);
this.msg = 'Error';
});
}
},
mounted() {
fetch('/api/domains/')
.then(response => response.json())
.then(data => {
this.domains = data;
});
try {
fetch('/local/domains')
.then(response => response.json())
.then(data => {
this.domains = data;
});
} catch (e) {
console.error('Error:', e);
}
}
}
</script>
@ -160,4 +203,8 @@ export default {
.is-invalid .invalid-feedback {
display: block;
}
.visually-hidden {
display: none;
}
</style>

308
frontend/src/views/Scan.vue Normal file
View file

@ -0,0 +1,308 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Scan a code</h1>
<div class="custom-control custom-switch mb-3">
<input class="custom-control-input" type="checkbox" id="visitFirstMatch"
v-model="visitFirstMatch">
<label class="custom-control-label" for="visitFirstMatch">Visit first match</label>
</div>
<div v-if="error" class="alert alert-danger" role="alert">{{ error }}</div>
<div v-if="!insecureContext" class="alert alert-warning">
This page isn't in a secure context ({{ insecureOrigin }}), so the camera API is
unavailable. From another device, use https:// (or http://localhost) instead.
</div>
<div class="row">
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Results</h5>
</div>
<div class="card-body">
<ul class="scan-results">
<li v-for="(c, i) in cameraLog" :key="i">
<span class="scan-result-type">{{ c.type }}</span>
<span v-if="c.metaText" class="text-muted"> {{ c.metaText }}</span>
<span class="text-muted"> @ {{ c.time }}</span>
<div class="text-break">
<a v-if="c.link?.href" :href="c.link.href" target="_blank"
rel="noopener noreferrer">{{ c.text }}</a>
<template v-else-if="c.link">
<router-link :to="c.link.to">{{ c.text }}
<span v-if="c.error" class="text-danger"> &rarr; {{
c.error
}}</span>
<span v-else-if="c.description"> &rarr; {{ c.description }}</span>
<span v-else-if="c.description === undefined" class="text-muted"> &rarr; resolving&hellip;</span>
</router-link>
</template>
<template v-else>{{ c.text }}</template>
</div>
</li>
</ul>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card h-100">
<div class="card-header">
<h5 class="card-title mb-0">Scan from camera</h5>
</div>
<div class="card-body">
<CameraScanner @scan="onCameraScan" @error="e => error = e"/>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import CameraScanner from "@/components/CameraScanner.vue";
import {encodeHandleForUrl, expandedRoute, OWNED_KIND_FIELDS} from "@/router";
import {decodeShortId, deserializeShortId, isDomainQualifiedShortId, decodeDomainQualifiedShortId} from "@/short-id";
const OWNER_QUALIFIED_ID_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):([is])(\d+)$/;
function classifyScanText(text) {
if (!text) {
return null;
}
try {
const url = new URL(text);
return url.origin === window.location.origin
? {to: url.pathname + url.search + url.hash, immediate: true}
: {href: url.href};
} catch {
// Not an absolute URL - fall through to the other known formats below.
}
if (text.startsWith('~') || isDomainQualifiedShortId(text)) {
try {
const qualified = isDomainQualifiedShortId(text) ? decodeDomainQualifiedShortId(text) : null;
const decoded = deserializeShortId(qualified ? qualified.ints : decodeShortId(text));
return {to: (!qualified && expandedRoute(decoded)) || '/' + text, decoded, domain: qualified?.domain};
} catch {
return null; // looked like a short id but isn't a real one - leave as plain text
}
}
const ownerMatch = text.match(OWNER_QUALIFIED_ID_RE);
if (ownerMatch) {
const [, handle, kindLetter, id] = ownerMatch;
const kind = kindLetter === "s" ? "storage_location" : "item";
const to = kind === "storage_location"
? `/storage-locations/${encodeHandleForUrl(handle)}/${id}`
: `/inventory/${encodeHandleForUrl(handle)}/${id}`;
return {to, ownerQualifiedId: {handle, kind, id}};
}
return null;
}
// Formats a decode result's metadata (see anyd-qr.js's SymbolMetadata) as a short string like
// "(v7, ec=M, mask=3)".
function metaSummary(metadata) {
const parts = [];
if (metadata.version != null) {
parts.push(`v${metadata.version}`);
}
if (metadata.size) {
parts.push(metadata.size);
}
if (metadata.ecc) {
parts.push(`ec=${metadata.ecc}`);
}
if (metadata.mask != null) {
parts.push(`mask=${metadata.mask}`);
}
return parts.length ? `(${parts.join(", ")})` : "";
}
export default {
name: "Scan",
components: {
BaseLayout,
CameraScanner,
},
data() {
return {
error: null,
insecureContext: window.isSecureContext,
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
visitFirstMatch: this.$route.query.navigate === "immediate",
cameraLog: [],
};
},
methods: {
...mapActions(["fetchItemByHandle", "fetchStorageLocationByHandle", "fetchIdMap", "resolveShortId"]),
resolveDescription(entry) {
const {link, text} = entry;
if (!link || link.href) {
return; // full URLs already show where they go - see this feature's ask
}
if (link.immediate) {
this.maybeVisitFirstMatch(link);
return;
}
if (!this.descriptionCache.has(text)) {
this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
}
this.descriptionCache.get(text).then(result => {
if (result && typeof result === "object") {
entry.description = null;
entry.error = result.error;
return;
}
entry.description = result;
if (result) {
this.maybeVisitFirstMatch(link);
}
});
},
maybeVisitFirstMatch(link) {
if (!this.visitFirstMatch || !link?.to) {
return;
}
this.visitFirstMatch = false;
this.$router.push(link.to);
},
async describeLink(link) {
if (link.ownerQualifiedId) {
const {handle, kind, id} = link.ownerQualifiedId;
return kind === "storage_location" ? this.describeLocation(handle, id) : this.describeItem(handle, id);
}
const decoded = link.decoded;
if (!decoded) {
return null;
}
if (link.domain) {
return this.describeForeignShortId(link.domain, decoded);
}
if (decoded.kind === "item" || decoded.kind === "group_item") {
const byId = decoded.kind === "item"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById;
const ownerId = decoded.kind === "item" ? decoded.owner_identity_id : decoded.owner_group_id;
let handle = byId[ownerId];
if (handle === undefined) {
await this.fetchIdMap();
handle = (decoded.kind === "item"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById)[ownerId];
}
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
}
if (decoded.kind === "group") {
let handle = this.$store.getters.groupHandleById[decoded.group_id];
if (handle === undefined) {
await this.fetchIdMap();
handle = this.$store.getters.groupHandleById[decoded.group_id];
}
return handle === undefined ? null : handle;
}
if (decoded.kind === "storage_location" || decoded.kind === "group_storage_location") {
const byId = decoded.kind === "storage_location"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById;
const ownerId = decoded.kind === "storage_location" ? decoded.owner_identity_id : decoded.owner_group_id;
let handle = byId[ownerId];
if (handle === undefined) {
await this.fetchIdMap();
handle = (decoded.kind === "storage_location"
? this.$store.getters.identityHandleById
: this.$store.getters.groupHandleById)[ownerId];
}
return handle === undefined ? null : this.describeLocation(handle, decoded.storage_location_id);
}
return null; // workflow, category, file: no per-item title lookup wired up yet
},
async describeForeignShortId(domain, decoded) {
const owned = OWNED_KIND_FIELDS[decoded.kind];
if (!owned) {
return null; // no owner id to resolve a foreign backend against yet (group/category/file/workflow)
}
const resolved = await this.resolveShortId({
domain, kind: decoded.kind, ownerId: decoded[owned.ownerField], localId: decoded[owned.localField],
});
if (!resolved) {
return null;
}
return owned.isLocation
? this.describeLocation(resolved.handle, resolved.id)
: this.describeItem(resolved.handle, resolved.id);
},
async describeItem(handle, id) {
const item = await this.fetchItemByHandle({handle, id});
return item ? `[#${item.id}] ${item.name}` : null;
},
async describeLocation(handle, id) {
const location = await this.fetchStorageLocationByHandle({handle, id});
return location ? `[#${location.id}] ${location.name}` : null;
},
// Handler for CameraScanner's "scan" event - logs every code from the batch it decoded.
onCameraScan(codes) {
codes.forEach(this.logDecode);
},
logDecode(code) {
const text = code.text ?? "";
const entry = {
type: code.type,
text,
metaText: metaSummary(code.metadata),
time: new Date().toLocaleTimeString(),
link: classifyScanText(text),
description: undefined,
error: null,
};
this.cameraLog.unshift(entry);
this.resolveDescription(this.cameraLog[0]);
this.cameraLog.length = Math.min(this.cameraLog.length, 20);
},
},
created() {
this.descriptionCache = new Map();
},
}
</script>
<style scoped>
.scan-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: .4rem;
overflow-y: auto;
}
.scan-results li {
border: 1px solid var(--bs-gray-300);
border-radius: .35rem;
padding: .4rem .6rem;
font-size: .85rem;
}
.scan-result-type {
font-weight: 600;
color: var(--bs-success);
}
</style>

View file

@ -0,0 +1,137 @@
<template>
<BaseLayout hide-search>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Search Inventories</h1>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Results for "{{ query }}"</h5>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
<tr>
<th style="width:40%;">Name</th>
<th style="width:25%">Owner</th>
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in search_results" :key="item.id">
<td>
<router-link :to="item.route">{{ item.name }}</router-link>
</td>
<td>
<user-name-tag :user="item"/>
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
</td>
</tr>
</tbody>
</table>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in search_results"
:key="item.id">
<div class="card">
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview"/>
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="item.route">
{{ item.name }}
</router-link></h5>
<div class="card-text text-black-50">
<user-name-tag :user="item" :width="24" :height="24"/>
<br>
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</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 SearchBox from "@/components/SearchBox.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import UserNameTag from "@/components/UserNameTag.vue";
import {encodeHandleForUrl} from "@/router";
export default {
name: 'Search',
components: {
SearchBox,
AuthenticatedImage,
UserNameTag,
...BIcons,
BaseLayout
},
props: {
query: {
type: String,
required: true
}
},
data() {
return {
search_results: [],
layout: 'table'
}
},
methods: {
...mapActions(['fetchSearchResults']),
only_images(files) {
if (!files) return [];
return files.filter(file => file.mime_type.startsWith("image/"));
},
loadResults() {
// Search results are always personal-or-friend (inventory_items() in
// toolshed/api/inventory.py never yields group items), so owner is always a plain
// user handle - one route shape, no owner-is-me special case.
this.fetchSearchResults({query: this.query}).then((results) => {
this.search_results = results.map(e => (
{...e, route: `/inventory/${encodeHandleForUrl(e.owner)}/${e.id}`}))
});
}
},
watch: {
query: {
immediate: false,
handler() {
this.loadResults();
}
}
},
mounted() {
this.loadResults();
}
}
</script>
<style scoped>
.img-preview {
object-fit: cover;
}
</style>

View file

@ -0,0 +1,111 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Settings</h1>
<div v-if="deleteError" class="alert alert-danger">{{ deleteError }}</div>
<div v-if="deleteSuccess" class="alert alert-success">
Your account has been permanently deleted. Signing you out...
</div>
<div class="row">
<div class="col-md-3 col-xl-2">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Profile Settings</h5>
</div>
<div class="list-group list-group-flush" role="tablist">
<router-link class="list-group-item list-group-item-action" :to="{name: 'account'}"
active-class="dummy" exact-active-class="active">Account
</router-link>
<router-link class="list-group-item list-group-item-action" :to="{name: 'password'}"
active-class="active">Password
</router-link>
<router-link class="list-group-item list-group-item-action" :to="{name: 'privacy'}"
active-class="active">Privacy and safety
</router-link>
<router-link class="list-group-item list-group-item-action" :to="{name: 'email'}"
active-class="active">Email notifications
</router-link>
<router-link class="list-group-item list-group-item-action"
:to="{name: 'notifications'}" active-class="active">Web notifications
</router-link>
<router-link class="list-group-item list-group-item-action" :to="{name: 'preferences'}"
active-class="active">Preferences
</router-link>
<router-link class="list-group-item list-group-item-action" :to="{name: 'data'}"
active-class="active">Your data
</router-link>
<a class="list-group-item list-group-item-action delete" @click="deleteAccount"
href="#">
Delete account
</a>
</div>
</div>
</div>
<div class="col-md-9 col-xl-10">
<div class="tab-content">
<router-view></router-view>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapMutations} from "vuex";
export default {
name: 'Settings',
components: {BaseLayout},
data() {
return {
deleteError: null,
deleteSuccess: false
}
},
computed: {
...mapGetters(['signAuth'])
},
methods: {
...mapActions(['getHomeServers']),
...mapMutations(['logout']),
async deleteAccount() {
this.deleteError = null;
if (!confirm('Are you sure you want to permanently delete your account? ' +
'All your data will be deleted and you will not be able to log back in. This cannot be undone.')) {
return;
}
try {
const servers = await this.getHomeServers();
const response = await servers.delete(this.signAuth, '/api/v1/account/');
if (!response || !response.ok) {
const errorBody = response ? await response.json().catch(() => ({})) : {};
this.deleteError = 'Account deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error');
return;
}
this.deleteSuccess = true;
// logout() redirects to /login right away - give the user a moment to read the
// confirmation above before that navigation happens.
setTimeout(() => this.logout(), 2000);
} catch (error) {
console.error('Account deletion failed', error);
this.deleteError = 'Account deletion failed: ' + error;
}
},
}
}
</script>
<style scoped>
.delete {
color: var(--bs-danger);
}
.delete:hover {
color: var(--bs-light);
background-color: var(--bs-danger);
}
</style>

View file

@ -0,0 +1,198 @@
<template>
<BaseLayout hide-search>
<main class="content">
<div class="container-fluid p-0">
<div v-if="qualified" class="card">
<div class="card-header">Domain</div>
<div class="card-body">{{ qualified.domain }}</div>
</div>
<div class="card">
<div class="card-header">Decoded</div>
<div class="card-body">
<ul>
<li v-for="(value, index) in decoded" :key="index">{{ value }}</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header">Deserialized</div>
<div class="card-body">
<p>kind: {{ deserialized.kind }}</p>
<ul>
<li v-for="(value, name) in fields" :key="name">{{ name }}: {{ value }}</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header">Expanded</div>
<div class="card-body">
<router-link v-if="expandedRoute" :to="expandedRoute">{{ expandedRoute }}</router-link>
<span v-else-if="error" class="text-danger">{{ error }}</span>
<span v-else>No page exists for kind '{{ deserialized.kind }}'</span>
</div>
</div>
<div class="card">
<div class="card-header">Examples</div>
<table class="table table-striped mb-0">
<thead>
<tr>
<th>Kind</th>
<th>Fields</th>
<th>Serialized</th>
<th>Bits</th>
<th>Token</th>
</tr>
</thead>
<tbody>
<tr v-for="example in examples" :key="example.token">
<td>{{ example.kind }}</td>
<td>
<span v-for="(value, name) in example.fields" :key="name" class="me-2">
{{ name }}: {{ value }}
</span>
</td>
<td>[{{ example.ints.join(', ') }}]</td>
<td><code>{{ example.bits }}</code></td>
<td>
<router-link :to="'/' + example.token">{{ example.token }}</router-link>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions} from 'vuex';
import BaseLayout from '@/components/BaseLayout.vue';
import {
decodeShortId, deserializeShortId, encodeShortId, serializeShortId,
isDomainQualifiedShortId, decodeDomainQualifiedShortId
} from '@/short-id';
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP, domainQualifiedRoute} from '@/router';
const EXAMPLES = [
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
{kind: 'group', group_id: 11},
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
{kind: 'file', file_id: 123},
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
{kind: 'group_storage_location', owner_group_id: 5, storage_location_id: 1000},
{kind: 'category', category_id: 5},
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
];
// Debug-only mirror of short-id.js's bit-packing (2-bit kind tag + 4-bit chunks, see docs/handles-and-shortids.md), recomputed from `ints` for display only.
const KIND_TAG_BITS = 2;
const CHUNK_BITS = 4;
const DIRECT_KIND_COUNT = 2 ** KIND_TAG_BITS - 1;
function chunkedIntBits(value, chunkBits) {
value = BigInt(value);
const mask = (1n << BigInt(chunkBits)) - 1n;
const chunks = [];
let remaining = value;
do {
chunks.unshift(remaining & mask);
remaining >>= BigInt(chunkBits);
} while (remaining > 0n);
return chunks.map((chunk, i) => {
const continuation = i === chunks.length - 1 ? '0' : '1';
return continuation + chunk.toString(2).padStart(chunkBits, '0');
}).join('');
}
function shortIdBitSegments(ints) {
const [kindIndex, ...fieldValues] = ints;
const segments = [];
if (kindIndex < DIRECT_KIND_COUNT) {
segments.push(kindIndex.toString(2).padStart(KIND_TAG_BITS, '0'));
} else {
segments.push('1'.repeat(KIND_TAG_BITS));
segments.push(chunkedIntBits(kindIndex - DIRECT_KIND_COUNT, CHUNK_BITS));
}
fieldValues.forEach(value => segments.push(chunkedIntBits(value, CHUNK_BITS)));
const totalBits = segments.join('').length;
const paddingBits = (6 - (totalBits % 6)) % 6;
if (paddingBits > 0) {
segments.push('0'.repeat(paddingBits));
}
return segments.join(' ');
}
export default {
name: 'ShortId',
components: {
BaseLayout
},
props: {
short_id: {
type: String,
required: true
}
},
data() {
return {
error: null,
foreignRoute: undefined,
}
},
computed: {
// '<domain>:~<token>' vs a bare '~<token>' - see docs/handles-and-shortids.md#domain-qualified-short-id.
qualified() {
return isDomainQualifiedShortId(this.short_id) ? decodeDomainQualifiedShortId(this.short_id) : null;
},
decoded() {
return this.qualified ? this.qualified.ints : decodeShortId(this.short_id);
},
deserialized() {
return deserializeShortId(this.decoded);
},
fields() {
const {kind, ...fields} = this.deserialized;
return fields;
},
expandedRoute() {
return this.qualified ? (this.foreignRoute || null) : buildExpandedRoute(this.deserialized);
},
examples() {
return EXAMPLES.map(named => {
const {kind, ...fields} = named;
const ints = serializeShortId(named);
const token = encodeShortId(ints);
return {kind, fields, ints, token, bits: shortIdBitSegments(ints)};
});
}
},
watch: {
// expandedRoute re-evaluates once fetchIdMap resolves (see buildExpandedRoute in router.js); finishes the redirect the router's synchronous guard couldn't.
expandedRoute(url) {
if (url) {
this.$router.replace(url);
}
}
},
methods: {
...mapActions(['fetchIdMap']),
async resolveIfQualified() {
if (this.qualified) {
this.foreignRoute = await domainQualifiedRoute(this.qualified.domain, this.decoded) || null;
}
}
},
mounted() {
this.resolveIfQualified();
if (NEEDS_IDMAP.has(this.deserialized.kind) && !this.$store.state.idmapLoaded) {
this.fetchIdMap()
.then(() => this.resolveIfQualified())
.catch(e => {
this.error = e.message;
});
}
}
}
</script>

View file

@ -0,0 +1,240 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Storage Locations</h1>
<div class="row">
<div class="col-12 col-xl-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
<h5 class="card-title mb-0">{{ selectedOwner }}'s Storage Locations</h5>
<div class="d-flex align-items-center">
<!--label for="ownerSelect" class="visually-hidden">Viewing storage locations for</label-->
<select id="ownerSelect" class="form-select form-select-sm me-2" style="width:auto"
v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
</select>
<div class="btn-group">
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
</div>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
<tr>
<th style="width:35%;">Name</th>
<th style="width:20%">Path</th>
<th class="d-none d-md-table-cell" style="width:20%">Category</th>
<th class="d-none d-md-table-cell" style="width:20%">Visibility Policy</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="location in locations" :key="location.id">
<td>
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
</td>
<td class="d-none d-md-table-cell">
<span class="text-muted">{{ location.path }}</span>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
<span class="text-muted" v-else>-</span>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
</td>
<td class="table-action">
<router-link :to="`${locationRoute(location)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`${locationRoute(location)}/delete`" @click.prevent="tryDeleteLocation(location)">
<b-icon-trash></b-icon-trash>
</a>
<!--router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
<b-icon-link></b-icon-link>
</router-link-->
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
</table>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="location in locations"
:key="location.id">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="locationRoute(location)">
{{ location.name }}
</router-link>
</h5>
<div class="card-text text-black-50">
<small class="text-muted d-block">{{ location.path }}</small>
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
</div>
<div class="card-text" v-if="location.description">
<small>{{ location.description }}</small>
</div>
<div class="btn-group mt-2">
<button class="btn btn-danger btn-sm"
@click="tryDeleteLocation(location)">Delete
</button>
<router-link :to="`${locationRoute(location)}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
<!--router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
class="btn btn-secondary btn-sm">
<b-icon-link></b-icon-link>
</router-link-->
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)"
class="btn btn-secondary btn-sm">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
</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 {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
export default {
name: "StorageLocation",
props: {
// Matches /storage-location/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
owner: {
type: String,
default: null
}
},
components: {
BaseLayout,
...BIcons
},
computed: {
...mapGetters(["identityIdByHandle", "groupIdByHandle", "groupStorageLocations", "getPreference"]),
...mapState(["user", "storage_locations", "groups", "groupMemberships"]),
// Implicit preference: remembers the last-used view without a settings-page entry. See docs/implementation.md#preferences.
layout: {
get() {
return this.getPreference('ui.remembered.storage_location_view_mode', 'grid')
},
set(value) {
this.setDevicePreference({key: 'ui.remembered.storage_location_view_mode', value})
}
},
// Groups hosted here plus GroupMembership-only ones - see Groups.vue's allGroups for the same merge/dedupe.
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))
},
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
selectedOwner: {
get() {
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
},
set(value) {
const path = value === this.user ? '/storage-location' : `/storage-location/${encodeHandleForUrl(value)}`
this.$router.replace(path)
}
},
locations() {
return this.selectedOwner === this.user ? this.storage_locations : this.groupStorageLocations(this.selectedOwner)
},
addLocationRoute() {
return this.selectedOwner === this.user
? '/storage-locations/new'
: `/storage-locations/new/${encodeHandleForUrl(this.selectedOwner)}`
}
},
methods: {
...mapActions(["fetchStorageLocations", "fetchGroupStorageLocations", "deleteStorageLocation",
"fetchIdMap", "fetchGroups", "fetchGroupMemberships", "setDevicePreference"]),
fetchLocationsForOwner() {
return this.selectedOwner === this.user
? this.fetchStorageLocations()
: this.fetchGroupStorageLocations({groupHandle: this.selectedOwner})
},
tryDeleteLocation(location) {
this.deleteStorageLocation(location).then(() => {
this.fetchLocationsForOwner()
})
},
locationRoute(location) {
return `/storage-locations/${encodeHandleForUrl(location.owner_group || location.owner)}/${location.id}`
},
shortIdLink(location) {
if (location.owner_group) {
const owner_group_id = this.groupIdByHandle[location.owner_group]
if (owner_group_id === undefined) return null
return shortenedRoute({
kind: 'group_storage_location', owner_group_id, storage_location_id: location.id
})
}
const owner_identity_id = this.identityIdByHandle[location.owner]
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'storage_location', owner_identity_id, storage_location_id: location.id})
},
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
// printLinkFor. See docs/implementation.md#print-link-shape.
printLinkFor(location) {
const userHandle = location.owner_group || location.owner
return {path: '/print', query: {kind: 'storage-location', userHandle, id: location.id}}
},
},
watch: {
owner() {
this.fetchLocationsForOwner()
}
},
async mounted() {
this.fetchLocationsForOwner()
await this.fetchIdMap()
await this.fetchGroups()
await this.fetchGroupMemberships()
}
}
</script>
<style scoped>
.card-text small {
font-size: 0.8rem;
}
.btn-group.mt-2 {
width: 100%;
}
.btn-group.mt-2 .btn {
flex: 1;
}
</style>

Some files were not shown because too many files have changed in this diff Show more