Initial Commit

This commit is contained in:
Tom Butcher 2025-11-09 18:00:07 +00:00
commit 302416c83e
140 changed files with 23216 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
VITE_API_URL=https://api.thehideoutltd.com
VITE_TURNSTILE_KEY=0x4AAAAAAB2uebWFPXaK8spB

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
VITE_API_URL=https://thehideout.tombutcher.work/api
VITE_TURNSTILE_KEY=0x4AAAAAAB2dBq6i8m4kYzDm

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
### react ###
.DS_*
*.log
logs
**/*.backup.*
**/*.back.*
node_modules
bower_components
*.sublime*
*.wranger
dist
psd
thumb

7
assets/airbnblogo.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 93 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.07553,0,0,1.07553,-3.50531,-3.77271)">
<path d="M8.098,59.923C7.873,60.513 7.775,60.628 8.046,60.01C7.428,61.423 6.721,62.925 6.014,64.514C5.557,65.521 5.099,66.618 4.642,67.808C4.635,67.826 4.628,67.844 4.622,67.863C3.356,71.464 2.977,74.872 3.464,78.377C3.465,78.382 3.465,78.387 3.466,78.392C4.544,85.743 9.444,91.918 16.205,94.667C18.757,95.745 21.406,96.239 24.153,96.239C24.938,96.239 25.92,96.144 26.705,96.046C29.875,95.661 33.143,94.609 36.314,92.785C39.642,90.917 42.838,88.346 46.322,84.742C49.819,88.344 53.071,90.914 56.317,92.78C59.49,94.607 62.761,95.661 65.935,96.046C66.72,96.144 67.701,96.239 68.486,96.239C71.244,96.239 73.999,95.737 76.462,94.656C83.27,91.92 88.062,85.688 89.162,78.471C89.931,75.031 89.561,71.588 88.284,67.951C88.271,67.915 88.258,67.88 88.243,67.845C87.793,66.765 87.342,65.595 86.892,64.604C86.185,63.014 85.478,61.512 84.86,60.099L84.756,59.976C78.627,46.652 72.054,33.15 65.126,19.826L64.842,19.261C64.141,17.946 63.44,16.544 62.739,15.141C62.726,15.115 62.712,15.089 62.698,15.064C61.751,13.36 60.791,11.571 59.284,9.865C56.063,5.859 51.428,3.661 46.497,3.661C41.477,3.661 36.951,5.854 33.638,9.67C33.629,9.68 33.62,9.69 33.612,9.701C32.196,11.399 31.151,13.188 30.208,14.887C30.194,14.912 30.18,14.938 30.167,14.963C29.466,16.366 28.765,17.769 28.063,19.084L27.778,19.654C20.945,32.965 14.289,46.454 8.164,59.767L8.091,59.913L8.098,59.923ZM76.02,63.748C76.066,63.963 76.144,64.149 76.244,64.309C76.921,65.68 77.52,67.206 78.199,68.572C78.596,69.519 79.006,70.328 79.25,71.14C79.251,71.144 79.253,71.149 79.254,71.153C79.871,73.158 80.118,75.083 79.81,77.088C79.808,77.1 79.806,77.113 79.804,77.125C79.278,81.033 76.644,84.411 72.962,85.914C72.957,85.916 72.953,85.918 72.948,85.92C71.138,86.674 69.175,86.892 67.213,86.666C65.248,86.43 63.284,85.794 61.24,84.615C61.231,84.61 61.221,84.604 61.212,84.599C58.512,83.099 55.825,80.853 52.773,77.647C58.212,70.701 61.512,64.313 62.811,58.595C62.812,58.594 62.812,58.593 62.812,58.592C63.488,55.599 63.581,52.896 63.291,50.386C63.288,50.357 63.284,50.328 63.279,50.299C62.876,47.779 61.964,45.462 60.553,43.445C57.439,38.928 52.232,36.29 46.409,36.29C40.589,36.29 35.382,39.019 32.272,43.434C32.27,43.435 32.269,43.437 32.268,43.439C30.855,45.457 29.941,47.776 29.538,50.299C29.537,50.303 29.537,50.307 29.536,50.311C29.144,52.861 29.237,55.705 30.018,58.647C31.319,64.34 34.699,70.798 40.063,77.74C37.057,80.945 34.303,83.189 31.605,84.688C31.596,84.693 31.587,84.698 31.577,84.704C29.534,85.882 27.572,86.518 25.608,86.755C23.562,86.98 21.591,86.679 19.846,85.999C16.173,84.495 13.548,81.129 13.016,77.234C12.786,75.309 12.944,73.388 13.712,71.238C13.724,71.205 13.735,71.172 13.744,71.14C13.991,70.318 14.409,69.5 14.82,68.516C15.432,67.117 16.131,65.632 16.83,64.146C16.501,64.847 16.583,64.635 16.894,64.018L16.896,64.016L16.894,64.018L16.924,63.957C23.03,50.774 29.577,37.324 36.39,24.229L36.659,23.691C37.365,22.368 38.07,20.958 38.775,19.636C38.783,19.621 38.791,19.605 38.799,19.59C39.431,18.325 40.135,17.135 41.003,16.106C42.475,14.429 44.424,13.513 46.586,13.513C48.745,13.513 50.692,14.426 52.158,16.093C53.033,17.13 53.739,18.322 54.373,19.59C54.381,19.605 54.389,19.621 54.397,19.636C55.103,20.961 55.81,22.373 56.516,23.698C56.512,23.691 56.767,24.2 56.767,24.2C56.769,24.205 56.772,24.21 56.774,24.215C63.451,37.304 69.953,50.656 76.02,63.748ZM46.398,69.926C42.725,64.959 40.299,60.313 39.407,56.303C39.026,54.55 38.931,53.026 39.16,51.653C39.165,51.626 39.168,51.599 39.172,51.572C39.306,50.499 39.71,49.56 40.243,48.751C41.586,46.855 43.83,45.699 46.409,45.699C48.969,45.699 51.283,46.744 52.529,48.682C52.535,48.691 52.541,48.701 52.547,48.71C53.093,49.528 53.509,50.481 53.645,51.572C53.649,51.599 53.653,51.626 53.657,51.653C53.885,53.022 53.794,54.617 53.414,56.29C53.413,56.29 53.413,56.291 53.413,56.291C52.52,60.238 50.085,64.888 46.398,69.926Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

7
assets/atsymbolicon.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.929994,0,0,0.929994,1.174095,-2.322496)">
<path d="M0,36.955C0,57.215 14.129,70.24 34.701,70.24C39.721,70.24 43.059,69.831 45.19,69.022C47.036,68.311 47.795,67.258 47.795,65.552C47.795,63.68 46.65,62.399 44.3,62.399C43.615,62.399 42.996,62.461 42.283,62.522C40.332,62.863 38.483,63.13 35.796,63.13C18.917,63.13 7.977,53.104 7.977,37.119C7.977,21.358 18.571,10.675 34.012,10.675C48.382,10.675 58.483,19.73 58.483,34.588C58.483,41.765 56.203,46.541 52.574,46.541C50.163,46.541 48.823,44.803 48.823,41.779L48.823,25.366C48.823,22.413 47.217,20.762 44.31,20.762C41.458,20.762 39.845,22.413 39.845,25.366L39.845,26.517L39.134,26.517C37.564,22.841 34.087,20.762 29.67,20.762C21.815,20.762 16.509,27.262 16.509,37.025C16.509,47.096 22.023,53.898 30.32,53.898C34.949,53.898 38.078,51.703 39.748,47.623L40.411,47.623C41.554,51.552 45.273,53.834 50.401,53.834C60.29,53.834 66.293,45.844 66.293,33.558C66.293,15.593 53.116,3.573 34.027,3.573C13.952,3.573 0,17.093 0,36.955ZM32.788,46.024C28.936,46.024 26.592,42.752 26.592,37.328C26.592,31.909 28.968,28.59 32.795,28.59C36.659,28.59 39.066,31.887 39.066,37.298C39.066,42.723 36.66,46.024 32.788,46.024Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

17
assets/bookingcomlogo.svg Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1033 1197" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(0.837254,0,0,1,0,0)">
<rect x="0" y="0" width="1233.14" height="1196.77" style="fill:none;"/>
<g transform="matrix(1.19438,0,0,1,-829.044,-624.116)">
<g>
<g transform="matrix(0.820417,0,0,0.820417,126.517,219.541)">
<path d="M1136.2,1618.37L934.84,1618.17L934.84,1377.4C934.84,1325.96 954.795,1299.18 998.817,1293.07L1136.2,1293.07C1234.17,1293.07 1297.54,1354.84 1297.54,1454.82C1297.54,1557.5 1235.77,1618.27 1136.2,1618.37L1136.2,1618.37ZM934.84,1107.86L934.84,905.596C934.84,850.142 958.304,823.769 1009.75,820.36L1112.83,820.36C1201.18,820.36 1254.12,873.206 1254.12,961.751C1254.12,1029.14 1217.82,1107.86 1116.04,1107.86L934.84,1107.86ZM1393.31,1209.24L1356.91,1188.78L1388.7,1161.6C1425.7,1129.82 1487.67,1058.32 1487.67,934.977C1487.67,746.054 1341.16,624.217 1114.44,624.217L855.72,624.217L855.72,624.116L826.239,624.116C759.053,626.623 705.204,681.275 704.502,748.862L704.502,1820.83L1119.55,1820.83C1371.55,1820.83 1534.2,1683.65 1534.2,1471.16C1534.2,1356.74 1481.65,1258.97 1393.31,1209.24" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.820417,0,0,0.820417,126.517,219.541)">
<path d="M1642.29,1672.75C1642.29,1590.86 1708.34,1524.61 1789.71,1524.61C1871.29,1524.61 1937.65,1590.86 1937.65,1672.75C1937.65,1754.53 1871.29,1820.89 1789.71,1820.89C1708.34,1820.89 1642.29,1754.53 1642.29,1672.75" style="fill-rule:nonzero;"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

30
assets/checkicon.svg Normal file
View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
width="100%"
height="100%"
viewBox="0 0 56 56"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
xmlns:serif="http://www.serif.com/"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
>
<g>
<rect
x="0"
y="0"
width="55.415"
height="55.708"
style="fill-opacity:0; stroke: none;"
/>
<g transform="matrix(0.863271,0,0,0.863271,3.86933,3.15986)">
<path
d="M21.134,55.708C22.759,55.708 24.041,55.029 24.932,53.671L54.306,7.879C54.964,6.849 55.227,5.987 55.227,5.139C55.227,2.987 53.714,1.503 51.54,1.503C49.998,1.503 49.102,2.021 48.166,3.49L21.009,46.634L7.015,28.64C6.104,27.434 5.149,26.929 3.799,26.929C1.567,26.929 0,28.491 0,30.648C0,31.575 0.346,32.511 1.126,33.458L17.316,53.715C18.394,55.063 19.56,55.708 21.134,55.708Z"
style="fill-rule:nonzero;"
/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

10
assets/closeicon.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 52 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(0.8125,0,0,1,0,0)">
<rect x="0" y="0" width="64" height="64" style="fill:none;"/>
<g transform="matrix(1.118019,0,0,0.90839,2.461538,6.366532)">
<path d="M43.085,1.65L1.552,43.208C-0.514,45.249 -0.545,49.091 1.623,51.275C3.815,53.436 7.705,53.364 9.722,51.347L51.256,9.813C53.393,7.677 53.376,3.954 51.16,1.746C48.945,-0.47 45.253,-0.501 43.085,1.65ZM51.256,43.16L9.722,1.626C7.681,-0.399 3.832,-0.501 1.623,1.722C-0.561,3.938 -0.49,7.748 1.552,9.79L43.085,51.323C45.222,53.46 48.952,53.444 51.16,51.252C53.369,49.036 53.393,45.321 51.256,43.16Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
assets/digitalicon.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g><path d="M2.296,11.596l10.765,0c0.99,0 1.579,-0.589 1.579,-1.579l0,-6.754c0,-0.987 -0.589,-1.573 -1.579,-1.573l-10.765,0c-0.987,0 -1.576,0.586 -1.576,1.573l0,6.754c0,0.99 0.589,1.579 1.576,1.579Zm0.15,-1.37c-0.22,0 -0.356,-0.136 -0.356,-0.356l0,-6.454c0,-0.225 0.136,-0.359 0.356,-0.359l10.468,0c0.22,0 0.356,0.133 0.356,0.359l0,6.454c0,0.22 -0.136,0.356 -0.356,0.356l-10.468,0Zm3.322,2.549l3.825,0l0,-1.365l-3.825,0l0,1.365Zm-0.017,0.895l3.858,0c0.334,0 0.6,-0.281 0.6,-0.6c0,-0.32 -0.267,-0.598 -0.6,-0.598l-3.858,0c-0.334,0 -0.603,0.278 -0.603,0.598c0,0.32 0.27,0.6 0.603,0.6Z" style="fill-rule:nonzero;"/><path d="M7.68,8.911c1.256,0 2.271,-1.017 2.271,-2.271c0,-1.256 -1.015,-2.279 -2.271,-2.279c-1.251,0 -2.274,1.023 -2.274,2.279c0,1.254 1.023,2.271 2.274,2.271Zm0,-0.656c-0.909,0 -1.595,-0.692 -1.595,-1.615c0,-0.926 0.687,-1.623 1.595,-1.623c0.909,0 1.604,0.698 1.604,1.623c0,0.923 -0.689,1.615 -1.604,1.615Zm0,-4.456c-0.167,0 -0.306,0.136 -0.306,0.3l0,1.295c0,0.178 0.139,0.309 0.306,0.309c0.17,0 0.303,-0.139 0.303,-0.309l0,-1.295c0,-0.158 -0.122,-0.3 -0.303,-0.3Zm1.237,3.144l1.295,0c0.172,0 0.306,-0.145 0.306,-0.303c0,-0.167 -0.133,-0.306 -0.306,-0.306l-1.295,0c-0.161,0 -0.303,0.122 -0.303,0.306c0,0.164 0.139,0.303 0.303,0.303Zm-1.237,2.529c0.17,0 0.306,-0.139 0.306,-0.309l0,-1.292c0,-0.164 -0.125,-0.303 -0.306,-0.303c-0.164,0 -0.303,0.136 -0.303,0.303l0,1.292c0,0.181 0.139,0.309 0.303,0.309Zm-2.527,-2.529l1.295,0c0.164,0 0.3,-0.139 0.3,-0.303c0,-0.183 -0.139,-0.306 -0.3,-0.306l-1.295,0c-0.167,0 -0.306,0.133 -0.306,0.306c0,0.164 0.128,0.303 0.306,0.303Zm2.532,0.131c0.239,0 0.434,-0.197 0.434,-0.439c0,-0.239 -0.195,-0.436 -0.434,-0.436c-0.245,0 -0.442,0.197 -0.442,0.436c0,0.242 0.197,0.439 0.442,0.439Z" style="fill-rule:nonzero;"/></g></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 47 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(0.722344,0,0,1,0,0)">
<rect x="0" y="0" width="64" height="64" style="fill:none;"/>
<g transform="matrix(1.185526,0,0,0.856358,0.559303,-7.877588)">
<path d="M52.969,66.594C52.969,63.312 50.312,60.656 47.031,60.656L5.938,60.656C2.625,60.656 0,63.312 0,66.594C0,69.875 2.625,72.531 5.938,72.531L47.031,72.531C50.312,72.531 52.969,69.875 52.969,66.594ZM5.906,28.438C2.562,28.438 0,30.844 0,34.438C0,36.094 0.688,37.656 1.938,38.906L21.906,58.875C22.969,59.969 24.812,60.656 26.5,60.656C28.188,60.656 30,59.969 31.094,58.875L51,38.906C52.281,37.656 52.969,36.094 52.969,34.438C52.969,30.844 50.406,28.438 47.062,28.438C45.094,28.438 43.688,29.344 42.562,30.5L35.656,37.5L26.5,48.781L17.344,37.5L10.406,30.5C9.25,29.344 7.875,28.438 5.906,28.438ZM32.062,49.656L32.781,34.844L32.781,15.995C32.781,11.995 30.281,9.338 26.5,9.338C22.719,9.338 20.219,11.995 20.219,15.995L20.219,34.844L20.906,49.656C21.062,52.75 23.406,55.219 26.5,55.219C29.594,55.219 31.906,52.75 32.062,49.656Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

15
assets/dropdownicon.svg Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 50 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(0.899381,0,0,0.930487,0,0)">
<rect x="0" y="0" width="55.594" height="68.781" style="fill:none;"/>
<clipPath id="_clip1">
<rect x="0" y="0" width="55.594" height="68.781"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g transform="matrix(0.945193,0,0,0.913596,-0,16.66748)">
<path d="M29.424,37.292C31.399,37.268 32.944,36.55 34.574,34.953L57.09,11.891C58.266,10.715 58.817,9.359 58.817,7.695C58.817,4.303 56.047,1.525 52.727,1.525C51.062,1.525 49.484,2.196 48.212,3.531L28.405,24.108L30.531,24.108L10.629,3.531C9.341,2.243 7.787,1.525 6.091,1.525C2.747,1.525 0,4.303 0,7.695C0,9.335 0.575,10.691 1.703,11.899L24.267,34.953C25.912,36.598 27.466,37.292 29.424,37.292Z" style="fill-rule:nonzero;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.980301,0,0,0.980301,1,1.490504)">
<path d="M5.052,29.984L22.276,29.984C27.064,29.984 29.862,27.178 29.862,22.39L29.862,6.009C29.862,3.095 27.617,1.05 24.719,1.05C21.798,1.05 19.584,3.088 19.584,6.009L19.584,7.011L20.311,13.333L16.001,8.759L8.961,1.65C7.977,0.682 6.661,0.162 5.29,0.162C2.148,0.162 0,2.424 0,5.582C0,6.92 0.599,8.107 1.543,9.076L8.662,16.146L13.235,20.425L6.77,19.705L5.052,19.705C2.13,19.705 0.085,21.912 0.085,24.841C0.085,27.786 2.123,29.984 5.052,29.984ZM38.483,61.225C41.404,61.225 43.618,59.194 43.618,56.266L43.618,55.207L42.89,48.868L47.201,53.465L54.292,60.595C55.253,61.587 56.561,62.083 57.963,62.083C61.098,62.083 63.246,59.821 63.246,56.687C63.246,55.325 62.654,54.138 61.686,53.194L54.54,46.048L49.935,41.768L56.432,42.512L58.231,42.512C61.152,42.512 63.198,40.306 63.198,37.384C63.198,34.431 61.16,32.241 58.231,32.241L40.925,32.241C36.114,32.241 33.34,35.016 33.34,39.803L33.34,56.266C33.34,59.187 35.554,61.225 38.483,61.225Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

7
assets/fullscreen.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.825872,0,0,0.825872,1,6.803958)">
<path d="M75.072,11.693L75.072,49.331C75.072,56.748 70.843,60.969 63.398,60.969L11.675,60.969C4.236,60.969 0,56.748 0,49.331L0,11.693C0,4.253 4.236,0.048 11.675,0.048L63.398,0.048C70.843,0.048 75.072,4.253 75.072,11.693ZM38.715,33.552C38.715,34.392 39.106,35.272 39.799,35.996L44.872,41.053L48.396,43.71L45.144,43.39L42.182,43.39C40.546,43.39 39.331,44.614 39.331,46.228C39.331,47.818 40.546,49.058 42.167,49.058L53.516,49.058C55.978,49.058 57.518,47.592 57.518,45.071L57.518,33.832C57.518,32.204 56.294,30.934 54.704,30.934C53.067,30.934 51.85,32.189 51.85,33.817L51.85,36.578L52.113,40.031L49.426,36.467L44.504,31.545C43.669,30.725 42.78,30.396 41.911,30.396C40.137,30.396 38.715,31.804 38.715,33.552ZM21.556,11.935C19.094,11.935 17.561,13.408 17.561,15.93L17.561,27.161C17.561,28.789 18.778,30.067 20.368,30.067C22.005,30.067 23.229,28.804 23.229,27.176L23.229,24.423L22.963,20.965L25.646,24.526L30.576,29.456C31.403,30.275 32.292,30.604 33.161,30.604C34.942,30.604 36.357,29.213 36.357,27.449C36.357,26.609 35.966,25.729 35.273,25.005L30.2,19.948L26.696,17.301L29.928,17.627L32.898,17.627C34.534,17.627 35.741,16.386 35.741,14.796C35.741,13.183 34.534,11.935 32.913,11.935L21.556,11.935Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 62 62" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-1.007339,-1)">
<g transform="matrix(0.998263,0,0,0.998263,1.007339,0.888199)">
<path d="M5.143,29.022C8.065,29.022 10.279,26.984 10.279,24.063L10.279,23.085L9.558,16.739L13.862,21.312L20.908,28.422C21.869,29.414 23.177,29.903 24.572,29.903C27.714,29.903 29.862,27.648 29.862,24.489C29.862,23.144 29.271,21.965 28.302,20.996L21.201,13.926L16.603,9.646L23.1,10.367L24.81,10.367C27.732,10.367 29.777,8.16 29.777,5.255C29.777,2.302 27.739,0.112 24.81,0.112L7.586,0.112C2.775,0.112 0,2.887 0,7.674L0,24.063C0,26.977 2.221,29.022 5.143,29.022ZM37.276,62.22L54.524,62.22C59.311,62.22 62.093,59.445 62.093,54.634L62.093,38.245C62.093,35.355 59.872,33.31 56.943,33.31C54.029,33.31 51.807,35.348 51.807,38.245L51.807,39.247L52.535,45.569L48.224,40.995L41.185,33.91C40.224,32.918 38.908,32.429 37.514,32.429C34.379,32.429 32.224,34.684 32.224,37.819C32.224,39.164 32.823,40.367 33.791,41.312L40.885,48.382L45.49,52.662L38.993,51.941L37.276,51.941C34.378,51.941 32.309,54.148 32.309,57.077C32.309,60.03 34.378,62.22 37.276,62.22Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
assets/iconloading.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.326574,0,0,0.326574,-293.114,-103.953)">
<path d="M947.978,337.608L901.718,337.608C900.662,337.608 899.702,337.2 898.838,336.384C897.974,335.568 897.542,334.632 897.542,333.576L897.542,322.344C897.542,321.288 897.974,320.352 898.838,319.536C899.702,318.72 900.662,318.312 901.718,318.312L967.817,318.312C971.199,318.312 974.203,319.676 976.827,322.403C979.554,325.027 980.918,328.031 980.918,331.413L980.918,397.512C980.918,398.568 980.51,399.528 979.694,400.392C978.878,401.256 977.942,401.688 976.886,401.688L965.654,401.688C964.598,401.688 963.662,401.256 962.846,400.392C962.03,399.528 961.622,398.568 961.622,397.512L961.622,351.252L912.36,400.515C911.613,401.262 910.646,401.652 909.458,401.686C908.27,401.72 907.302,401.363 906.556,400.617L898.614,392.674C897.867,391.928 897.51,390.96 897.544,389.772C897.578,388.585 897.969,387.617 898.715,386.87L947.978,337.608Z" style="fill:rgb(50,217,178);fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

8
assets/lefticon.svg Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="16px" height="16px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.900664,0,0,0.900141,0,5.83179)">
<rect x="0" y="0" width="71.059" height="58.143" style="fill-opacity:0;"/>
<path d="M0,29.061C0,30.199 0.474,31.533 1.689,32.681L26.424,55.923C28.054,57.447 29.405,58.123 31.083,58.123C33.494,58.123 35.262,56.329 35.262,53.949L35.262,42.08L60.317,42.08C65.141,42.08 67.966,39.332 67.966,34.565L67.966,23.645C67.966,18.878 65.141,16.119 60.317,16.119L35.262,16.119L35.262,4.307C35.262,1.927 33.494,0 31.031,0C29.376,0 28.216,0.679 26.424,2.359L1.678,25.422C0.442,26.595 0,27.875 0,29.061Z" style="fill:var(--th-textColor);fill-opacity:0.85;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

7
assets/linkicon.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 29 29" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.40093,0,0,0.40093,-9.85565e-08,6.19205e-07)">
<path d="M39.663,20.917L33,27.547C35.526,27.915 38.07,29.038 39.552,30.52C44.376,35.32 44.393,41.972 39.631,46.734L28.696,57.637C23.903,62.407 17.29,62.39 12.513,57.597C7.689,52.789 7.673,46.176 12.442,41.383L15.344,38.505C13.944,35.319 13.271,31.065 14.498,27.434L6.194,35.636C-2.11,43.828 -2.029,55.599 6.225,63.854C14.512,72.164 26.211,72.149 34.444,63.916L45.895,52.481C54.135,44.241 54.126,32.518 45.84,24.263C44.468,22.868 42.087,21.509 39.663,20.917ZM30.526,49.15L37.189,42.52C34.664,42.16 32.119,41.053 30.637,39.571C25.837,34.747 25.796,28.119 30.582,23.357L41.51,12.453C46.286,7.66 52.899,7.677 57.699,12.477C62.5,17.277 62.509,23.922 57.747,28.683L54.869,31.561C56.245,34.779 56.91,39.002 55.715,42.656L64.019,34.431C72.299,26.239 72.242,14.475 63.987,6.213C55.678,-2.073 43.978,-2.058 35.738,6.182L24.318,17.585C16.078,25.826 16.087,37.549 24.349,45.804C25.745,47.199 28.102,48.558 30.526,49.15Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

29
assets/loadingicon.svg Normal file
View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<svg
width="100%"
height="100%"
viewBox="0 0 64 64"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
class="th-icon"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"
>
<g>
<path
d="M32,5.131C46.829,5.131 58.869,17.171 58.869,32"
style="fill:none;stroke-width:7px;"
/>
<animateTransform
attributeName="transform"
attributeType="XML"
type="rotate"
from="0 32 32"
to="360 32 32"
dur="1s"
repeatCount="indefinite"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 791 B

12
assets/loggedouticon.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.804216,0,0,0.804216,-2.794893,1.816802)">
<g transform="matrix(1,0,0,1,-1.243447,-3.296793)">
<path d="M81.812,37.531C81.812,55.75 67.031,70.531 48.797,70.531C44.51,70.531 40.414,69.713 36.668,68.198C40.185,64.424 42.344,59.382 42.344,53.906C42.344,52.339 42.169,50.81 41.825,49.341C43.954,48.853 46.288,48.578 48.797,48.578C56.797,48.578 63.008,51.356 66.525,54.85C70.903,50.393 73.594,44.28 73.594,37.531C73.594,23.828 62.5,12.75 48.797,12.75C36.632,12.75 26.536,21.48 24.442,33.033C23.38,32.838 22.285,32.75 21.172,32.75C19.418,32.75 17.709,32.969 16.082,33.404C18.09,17.127 31.979,4.531 48.797,4.531C67.031,4.531 81.812,19.312 81.812,37.531ZM59.641,31.859C59.641,38.531 54.922,43.75 48.797,43.719C42.703,43.688 37.984,38.531 37.969,31.859C37.938,25.562 42.734,20.234 48.797,20.234C54.875,20.234 59.641,25.562 59.641,31.859Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,-1.243447,-3.296793)">
<path d="M37.625,53.906C37.625,62.891 30.125,70.375 21.172,70.375C12.156,70.375 4.719,62.922 4.719,53.906C4.719,44.906 12.156,37.469 21.172,37.469C30.188,37.469 37.625,44.891 37.625,53.906ZM25.266,46.172L21.141,50.312L17.203,46.391C16.25,45.453 14.625,45.453 13.672,46.391C12.719,47.359 12.734,48.969 13.672,49.922L17.594,53.859L13.453,57.984C12.453,58.969 12.531,60.641 13.516,61.578C14.484,62.531 16.141,62.641 17.125,61.641L21.25,57.516L25.141,61.406C26.125,62.375 27.703,62.344 28.672,61.391C29.641,60.422 29.641,58.859 28.672,57.875L24.781,53.969L28.922,49.828C29.906,48.828 29.781,47.25 28.828,46.266C27.859,45.281 26.266,45.188 25.266,46.172Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

19
assets/logo.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 221 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.266233,0,0,0.266233,-26.4149,-0.568275)">
<path d="M120.504,85.859C119.819,85.859 119.259,85.626 118.823,85.159C118.387,84.692 118.17,84.148 118.17,83.525L118.17,35.164L101.551,35.164C100.867,35.164 100.306,34.931 99.871,34.464C99.435,33.997 99.217,33.452 99.217,32.83L99.217,22.84C99.217,22.156 99.435,21.596 99.871,21.16C100.306,20.724 100.867,20.506 101.551,20.506L151.78,20.506C152.464,20.506 153.024,20.724 153.46,21.16C153.896,21.596 154.114,22.156 154.114,22.84L154.114,32.83C154.114,33.452 153.896,33.997 153.46,34.464C153.024,34.931 152.464,35.164 151.78,35.164L135.161,35.164L135.161,83.525C135.161,84.148 134.943,84.692 134.508,85.159C134.072,85.626 133.512,85.859 132.827,85.859L120.504,85.859Z" style="fill-rule:nonzero;"/>
<path d="M188.284,86.793C182.558,86.793 177.578,85.859 173.346,83.992C169.114,82.125 165.815,79.308 163.45,75.543C161.085,71.777 159.777,67.062 159.529,61.399C159.466,58.722 159.435,56.03 159.435,53.323C159.435,50.615 159.466,47.923 159.529,45.247C159.777,39.645 161.1,34.931 163.496,31.103C165.893,27.275 169.223,24.396 173.486,22.467C177.75,20.537 182.682,19.573 188.284,19.573C193.948,19.573 198.896,20.537 203.128,22.467C207.361,24.396 210.69,27.275 213.118,31.103C215.545,34.931 216.852,39.645 217.039,45.247C217.163,47.923 217.226,50.615 217.226,53.323C217.226,56.03 217.163,58.722 217.039,61.399C216.852,67.062 215.561,71.777 213.164,75.543C210.768,79.308 207.454,82.125 203.222,83.992C198.989,85.859 194.01,86.793 188.284,86.793ZM188.284,73.349C191.583,73.349 194.274,72.353 196.359,70.361C198.445,68.369 199.549,65.195 199.674,60.838C199.798,58.1 199.861,55.517 199.861,53.089C199.861,50.662 199.798,48.141 199.674,45.527C199.549,42.602 199.005,40.221 198.04,38.385C197.075,36.549 195.753,35.195 194.072,34.324C192.392,33.452 190.462,33.017 188.284,33.017C186.168,33.017 184.269,33.452 182.589,34.324C180.908,35.195 179.57,36.549 178.574,38.385C177.578,40.221 177.018,42.602 176.894,45.527C176.831,48.141 176.8,50.662 176.8,53.089C176.8,55.517 176.831,58.1 176.894,60.838C177.08,65.195 178.216,68.369 180.301,70.361C182.386,72.353 185.047,73.349 188.284,73.349Z" style="fill-rule:nonzero;"/>
<path d="M230.39,85.859C229.767,85.859 229.223,85.626 228.756,85.159C228.289,84.692 228.056,84.148 228.056,83.525L228.056,22.84C228.056,22.156 228.289,21.596 228.756,21.16C229.223,20.724 229.767,20.506 230.39,20.506L240.473,20.506C241.531,20.506 242.309,20.771 242.807,21.3C243.305,21.829 243.616,22.249 243.74,22.56L260.172,51.969L276.603,22.56C276.79,22.249 277.117,21.829 277.584,21.3C278.05,20.771 278.813,20.506 279.871,20.506L289.861,20.506C290.545,20.506 291.121,20.724 291.588,21.16C292.055,21.596 292.288,22.156 292.288,22.84L292.288,83.525C292.288,84.148 292.055,84.692 291.588,85.159C291.121,85.626 290.545,85.859 289.861,85.859L278.751,85.859C278.066,85.859 277.49,85.626 277.024,85.159C276.557,84.692 276.323,84.148 276.323,83.525L276.323,48.515L265.867,68.027C265.556,68.587 265.151,69.085 264.653,69.521C264.155,69.957 263.471,70.174 262.599,70.174L257.744,70.174C256.873,70.174 256.188,69.957 255.69,69.521C255.193,69.085 254.788,68.587 254.477,68.027L243.927,48.515L243.927,83.525C243.927,84.148 243.709,84.692 243.273,85.159C242.838,85.626 242.278,85.859 241.593,85.859L230.39,85.859Z" style="fill-rule:nonzero;"/>
<path d="M327.579,85.859C326.894,85.859 326.334,85.626 325.898,85.159C325.462,84.692 325.245,84.148 325.245,83.525L325.245,22.84C325.245,22.156 325.462,21.596 325.898,21.16C326.334,20.724 326.894,20.506 327.579,20.506L356.054,20.506C361.406,20.506 365.794,21.253 369.218,22.747C372.641,24.241 375.177,26.373 376.827,29.142C378.476,31.912 379.301,35.195 379.301,38.992C379.301,41.233 378.881,43.209 378.04,44.92C377.2,46.632 376.173,48.048 374.959,49.168C373.746,50.289 372.61,51.098 371.552,51.596C373.917,52.716 376.017,54.552 377.854,57.104C379.69,59.656 380.608,62.643 380.608,66.067C380.608,70.174 379.69,73.707 377.854,76.663C376.017,79.62 373.341,81.891 369.824,83.478C366.308,85.066 361.998,85.859 356.894,85.859L327.579,85.859ZM342.05,73.722L355.027,73.722C357.703,73.722 359.726,72.944 361.095,71.388C362.465,69.832 363.149,68.058 363.149,66.067C363.149,63.888 362.449,62.052 361.049,60.558C359.648,59.064 357.641,58.318 355.027,58.318L342.05,58.318L342.05,73.722ZM342.05,46.461L354.187,46.461C356.738,46.461 358.652,45.792 359.928,44.453C361.204,43.115 361.842,41.45 361.842,39.459C361.842,37.467 361.204,35.833 359.928,34.557C358.652,33.281 356.738,32.643 354.187,32.643L342.05,32.643L342.05,46.461Z" style="fill-rule:nonzero;"/>
<path d="M418.419,86.793C412.755,86.793 407.822,85.875 403.621,84.039C399.42,82.203 396.168,79.371 393.865,75.543C391.562,71.715 390.411,66.845 390.411,60.932L390.411,22.84C390.411,22.156 390.628,21.596 391.064,21.16C391.5,20.724 392.06,20.506 392.745,20.506L404.788,20.506C405.473,20.506 406.049,20.724 406.515,21.16C406.982,21.596 407.216,22.156 407.216,22.84L407.216,60.838C407.216,64.884 408.18,67.918 410.11,69.941C412.039,71.964 414.778,72.975 418.326,72.975C421.811,72.975 424.534,71.964 426.495,69.941C428.455,67.918 429.436,64.884 429.436,60.838L429.436,22.84C429.436,22.156 429.669,21.596 430.136,21.16C430.603,20.724 431.147,20.506 431.77,20.506L443.907,20.506C444.529,20.506 445.074,20.724 445.54,21.16C446.007,21.596 446.241,22.156 446.241,22.84L446.241,60.932C446.241,66.845 445.089,71.715 442.786,75.543C440.483,79.371 437.247,82.203 433.077,84.039C428.907,85.875 424.021,86.793 418.419,86.793Z" style="fill-rule:nonzero;"/>
<path d="M475.369,85.859C474.685,85.859 474.124,85.626 473.689,85.159C473.253,84.692 473.035,84.148 473.035,83.525L473.035,35.164L456.417,35.164C455.732,35.164 455.172,34.931 454.736,34.464C454.301,33.997 454.083,33.452 454.083,32.83L454.083,22.84C454.083,22.156 454.301,21.596 454.736,21.16C455.172,20.724 455.732,20.506 456.417,20.506L506.645,20.506C507.33,20.506 507.89,20.724 508.326,21.16C508.761,21.596 508.979,22.156 508.979,22.84L508.979,32.83C508.979,33.452 508.761,33.997 508.326,34.464C507.89,34.931 507.33,35.164 506.645,35.164L490.027,35.164L490.027,83.525C490.027,84.148 489.809,84.692 489.373,85.159C488.938,85.626 488.378,85.859 487.693,85.859L475.369,85.859Z" style="fill-rule:nonzero;"/>
<path d="M543.243,86.793C537.392,86.793 532.351,85.828 528.118,83.899C523.886,81.969 520.603,79.137 518.269,75.403C515.935,71.668 514.643,67.031 514.394,61.492C514.332,58.94 514.301,56.201 514.301,53.276C514.301,50.351 514.332,47.55 514.394,44.874C514.643,39.459 515.95,34.853 518.315,31.056C520.681,27.259 523.979,24.396 528.212,22.467C532.444,20.537 537.454,19.573 543.243,19.573C547.351,19.573 551.147,20.086 554.633,21.113C558.118,22.14 561.168,23.634 563.782,25.595C566.396,27.555 568.435,29.92 569.897,32.69C571.36,35.46 572.123,38.587 572.185,42.073C572.247,42.633 572.076,43.1 571.671,43.473C571.267,43.847 570.784,44.033 570.224,44.033L557.62,44.033C556.811,44.033 556.189,43.862 555.753,43.52C555.318,43.178 554.944,42.54 554.633,41.606C553.762,38.307 552.346,36.051 550.385,34.837C548.424,33.624 546.013,33.017 543.149,33.017C539.726,33.017 537.019,33.966 535.027,35.864C533.035,37.763 531.946,40.921 531.759,45.34C531.573,50.444 531.573,55.672 531.759,61.025C531.946,65.444 533.035,68.603 535.027,70.501C537.019,72.4 539.726,73.349 543.149,73.349C546.013,73.349 548.44,72.726 550.432,71.482C552.423,70.237 553.824,67.996 554.633,64.76C554.882,63.826 555.24,63.188 555.707,62.846C556.173,62.503 556.811,62.332 557.62,62.332L570.224,62.332C570.784,62.332 571.267,62.519 571.671,62.892C572.076,63.266 572.247,63.733 572.185,64.293C572.123,67.778 571.36,70.906 569.897,73.676C568.435,76.445 566.396,78.81 563.782,80.771C561.168,82.732 558.118,84.225 554.633,85.252C551.147,86.279 547.351,86.793 543.243,86.793Z" style="fill-rule:nonzero;"/>
<path d="M584.322,85.859C583.637,85.859 583.077,85.641 582.641,85.206C582.206,84.77 581.988,84.21 581.988,83.525L581.988,22.84C581.988,22.156 582.206,21.596 582.641,21.16C583.077,20.724 583.637,20.506 584.322,20.506L596.365,20.506C597.05,20.506 597.626,20.724 598.092,21.16C598.559,21.596 598.793,22.156 598.793,22.84L598.793,45.34L621.76,45.34L621.76,22.84C621.76,22.156 621.977,21.596 622.413,21.16C622.849,20.724 623.409,20.506 624.094,20.506L636.137,20.506C636.822,20.506 637.382,20.724 637.818,21.16C638.253,21.596 638.471,22.156 638.471,22.84L638.471,83.525C638.471,84.148 638.253,84.692 637.818,85.159C637.382,85.626 636.822,85.859 636.137,85.859L624.094,85.859C623.409,85.859 622.849,85.626 622.413,85.159C621.977,84.692 621.76,84.148 621.76,83.525L621.76,60.278L598.793,60.278L598.793,83.525C598.793,84.148 598.559,84.692 598.092,85.159C597.626,85.626 597.05,85.859 596.365,85.859L584.322,85.859Z" style="fill-rule:nonzero;"/>
<path d="M653.876,85.859C653.191,85.859 652.631,85.626 652.195,85.159C651.76,84.692 651.542,84.148 651.542,83.525L651.542,22.84C651.542,22.156 651.76,21.596 652.195,21.16C652.631,20.724 653.191,20.506 653.876,20.506L697.475,20.506C698.16,20.506 698.72,20.724 699.156,21.16C699.592,21.596 699.809,22.156 699.809,22.84L699.809,31.616C699.809,32.301 699.592,32.861 699.156,33.297C698.72,33.733 698.16,33.95 697.475,33.95L667.693,33.95L667.693,46.554L695.421,46.554C696.106,46.554 696.666,46.788 697.102,47.254C697.538,47.721 697.756,48.297 697.756,48.981L697.756,57.104C697.756,57.726 697.538,58.271 697.102,58.738C696.666,59.205 696.106,59.438 695.421,59.438L667.693,59.438L667.693,72.415L698.222,72.415C698.907,72.415 699.467,72.633 699.903,73.069C700.339,73.504 700.556,74.065 700.556,74.749L700.556,83.525C700.556,84.148 700.339,84.692 699.903,85.159C699.467,85.626 698.907,85.859 698.222,85.859L653.876,85.859Z" style="fill-rule:nonzero;"/>
<path d="M713.534,85.859C712.849,85.859 712.289,85.626 711.853,85.159C711.417,84.692 711.2,84.148 711.2,83.525L711.2,22.84C711.2,22.156 711.417,21.596 711.853,21.16C712.289,20.724 712.849,20.506 713.534,20.506L738.928,20.506C747.019,20.506 753.368,22.358 757.973,26.061C762.579,29.765 764.882,35.008 764.882,41.793C764.882,46.212 763.824,49.946 761.708,52.996C759.592,56.046 756.791,58.349 753.305,59.905L766.096,82.872C766.283,83.245 766.376,83.587 766.376,83.899C766.376,84.396 766.174,84.848 765.769,85.252C765.365,85.657 764.913,85.859 764.415,85.859L751.998,85.859C750.816,85.859 749.944,85.564 749.384,84.972C748.824,84.381 748.419,83.836 748.171,83.338L737.527,62.799L728.005,62.799L728.005,83.525C728.005,84.148 727.771,84.692 727.304,85.159C726.838,85.626 726.262,85.859 725.577,85.859L713.534,85.859ZM728.005,49.542L738.741,49.542C741.666,49.542 743.86,48.826 745.323,47.394C746.786,45.963 747.517,44.033 747.517,41.606C747.517,39.179 746.817,37.218 745.416,35.724C744.016,34.23 741.791,33.484 738.741,33.484L728.005,33.484L728.005,49.542Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.326574,0,0,0.326574,-100.006,-103.953)">
<path d="M947.978,337.608L901.718,337.608C900.662,337.608 899.702,337.2 898.838,336.384C897.974,335.568 897.542,334.632 897.542,333.576L897.542,322.344C897.542,321.288 897.974,320.352 898.838,319.536C899.702,318.72 900.662,318.312 901.718,318.312L967.817,318.312C971.199,318.312 974.203,319.676 976.827,322.403C979.554,325.027 980.918,328.031 980.918,331.413L980.918,397.512C980.918,398.568 980.51,399.528 979.694,400.392C978.878,401.256 977.942,401.688 976.886,401.688L965.654,401.688C964.598,401.688 963.662,401.256 962.846,400.392C962.03,399.528 961.622,398.568 961.622,397.512L961.622,351.252L912.36,400.515C911.613,401.262 910.646,401.652 909.458,401.686C908.27,401.72 907.302,401.363 906.556,400.617L898.614,392.674C897.867,391.928 897.51,390.96 897.544,389.772C897.578,388.585 897.969,387.617 898.715,386.87L947.978,337.608Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

35
assets/menuicon.svg Normal file
View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
class="th-menu-icon open"
width="64"
height="64"
viewBox="0 0 64 64"
xmlns="http://www.w3.org/2000/svg"
>
<g class="th-line line1">
<path
d="M8 14 H56"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
</g>
<g class="th-line line2">
<path
d="M8 32 H56"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
</g>
<g class="th-line line3">
<path
d="M8 50 H56"
stroke="black"
stroke-width="8"
stroke-linecap="round"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 880 B

7
assets/mloading.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.416638,0,0,0.416638,-94.7833,-8.54373)">
<path d="M230.39,85.859C229.767,85.859 229.223,85.626 228.756,85.159C228.289,84.692 228.056,84.148 228.056,83.525L228.056,22.84C228.056,22.156 228.289,21.596 228.756,21.16C229.223,20.724 229.767,20.506 230.39,20.506L240.473,20.506C241.531,20.506 242.309,20.771 242.807,21.3C243.305,21.829 243.616,22.249 243.74,22.56L260.172,51.969L276.603,22.56C276.79,22.249 277.117,21.829 277.584,21.3C278.05,20.771 278.813,20.506 279.871,20.506L289.861,20.506C290.545,20.506 291.121,20.724 291.588,21.16C292.055,21.596 292.288,22.156 292.288,22.84L292.288,83.525C292.288,84.148 292.055,84.692 291.588,85.159C291.121,85.626 290.545,85.859 289.861,85.859L278.751,85.859C278.066,85.859 277.49,85.626 277.024,85.159C276.557,84.692 276.323,84.148 276.323,83.525L276.323,48.515L265.867,68.027C265.556,68.587 265.151,69.085 264.653,69.521C264.155,69.957 263.471,70.174 262.599,70.174L257.744,70.174C256.873,70.174 256.188,69.957 255.69,69.521C255.193,69.085 254.788,68.587 254.477,68.027L243.927,48.515L243.927,83.525C243.927,84.148 243.709,84.692 243.273,85.159C242.838,85.626 242.278,85.859 241.593,85.859L230.39,85.859Z" style="fill:rgb(51,217,178);fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

8
assets/muteicon.svg Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.960666,0,0,0.960666,-4.884753,-3.57933)">
<path d="M53.839,62.642C52.977,64.606 51.036,65.913 48.698,65.913C46.592,65.913 44.991,65.128 43.005,63.276L30.695,51.773C30.524,51.633 30.346,51.611 30.152,51.611L21.613,51.611C15.962,51.611 12.862,48.367 12.862,42.475L12.862,30.946C12.862,27.944 13.669,25.633 15.214,24.095L53.839,62.642ZM54.33,13.186L54.33,42.754L31.988,20.423L43.005,10.199C45.1,8.238 46.697,7.493 48.66,7.493C51.883,7.493 54.33,9.985 54.33,13.186Z" style="fill-rule:nonzero;"/>
<path d="M65.637,68.12C66.718,69.177 68.464,69.201 69.52,68.12C70.546,67.056 70.577,65.317 69.52,64.26L11.191,5.961C10.11,4.881 8.332,4.881 7.276,5.961C6.226,7.011 6.226,8.796 7.276,9.845L65.637,68.12Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

7
assets/oloading.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.405065,0,0,0.405065,-62.6718,-7.92823)">
<path d="M188.284,86.793C182.558,86.793 177.578,85.859 173.346,83.992C169.114,82.125 165.815,79.308 163.45,75.543C161.085,71.777 159.777,67.062 159.529,61.399C159.466,58.722 159.435,56.03 159.435,53.323C159.435,50.615 159.466,47.923 159.529,45.247C159.777,39.645 161.1,34.931 163.496,31.103C165.893,27.275 169.223,24.396 173.486,22.467C177.75,20.537 182.682,19.573 188.284,19.573C193.948,19.573 198.896,20.537 203.128,22.467C207.361,24.396 210.69,27.275 213.118,31.103C215.545,34.931 216.852,39.645 217.039,45.247C217.163,47.923 217.226,50.615 217.226,53.323C217.226,56.03 217.163,58.722 217.039,61.399C216.852,67.062 215.561,71.777 213.164,75.543C210.768,79.308 207.454,82.125 203.222,83.992C198.989,85.859 194.01,86.793 188.284,86.793ZM188.284,73.349C191.583,73.349 194.274,72.353 196.359,70.361C198.445,68.369 199.549,65.195 199.674,60.838C199.798,58.1 199.861,55.517 199.861,53.089C199.861,50.662 199.798,48.141 199.674,45.527C199.549,42.602 199.005,40.221 198.04,38.385C197.075,36.549 195.753,35.195 194.072,34.324C192.392,33.452 190.462,33.017 188.284,33.017C186.168,33.017 184.269,33.452 182.589,34.324C180.908,35.195 179.57,36.549 178.574,38.385C177.578,40.221 177.018,42.602 176.894,45.527C176.831,48.141 176.8,50.662 176.8,53.089C176.8,55.517 176.831,58.1 176.894,60.838C177.08,65.195 178.216,68.369 180.301,70.361C182.386,72.353 185.047,73.349 188.284,73.349Z" style="fill:rgb(50,217,178);fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

7
assets/padlockicon.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.900985,0,0,0.900985,9.022595,2)">
<path d="M8.156,66.594L40.469,66.594C45.906,66.594 48.625,63.844 48.625,57.875L48.625,33C48.625,27.125 45.906,24.375 40.469,24.375L8.156,24.375C2.688,24.375 0,27.125 0,33L0,57.875C0,63.844 2.688,66.594 8.156,66.594ZM12.375,56.969C11.281,56.969 10.719,56.406 10.719,55.188L10.719,35.75C10.719,34.5 11.281,34 12.375,34L36.281,34C37.375,34 37.875,34.5 37.875,35.75L37.875,55.188C37.875,56.406 37.375,56.969 36.281,56.969L12.375,56.969ZM6.844,28L16.719,28L16.719,17.562C16.719,12.812 19.875,9.594 24.312,9.594C28.688,9.594 31.938,12.812 31.938,17.562L31.938,28L41.812,28L41.812,17.656C41.812,7.125 34.438,0 24.312,0C14.188,0 6.844,7.125 6.844,17.656L6.844,28Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

7
assets/pauseicon.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1.020373,0,0,1.020373,8.724516,2)">
<path d="M5.469,58.802L13.478,58.802C17.08,58.802 18.923,56.959 18.923,53.325L18.923,5.469C18.923,1.902 17.08,0 13.478,0L5.469,0C1.843,0 0,1.836 0,5.469L0,53.325C0,56.959 1.748,58.802 5.469,58.802ZM32.151,58.802L40.152,58.802C43.785,58.802 45.621,56.959 45.621,53.325L45.621,5.469C45.621,1.902 43.785,0 40.152,0L32.151,0C28.518,0 26.674,1.836 26.674,5.469L26.674,53.325C26.674,56.959 28.446,58.802 32.151,58.802Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 971 B

7
assets/personicon.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 27 29" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.505548,0,0,0.505548,-3.55271e-15,0.242431)">
<path d="M4.239,54.763L47.512,54.763C50.042,54.763 51.751,53.317 51.751,51.218C51.751,43.271 41.832,32.321 25.874,32.321C9.919,32.321 0,43.271 0,51.218C0,53.317 1.709,54.763 4.239,54.763ZM25.964,26.698C32.826,26.698 38.028,20.814 38.028,13.205C38.028,6.097 32.709,0 25.964,0C19.218,0 13.871,6.076 13.871,13.209C13.871,20.814 19.1,26.698 25.964,26.698Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 921 B

7
assets/playicon.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.997675,0,0,0.997675,1.373455,2)">
<path d="M6.819,53.357C6.819,57.812 9.627,60.14 12.994,60.14C14.385,60.14 15.903,59.732 17.325,58.921L55.88,36.446C58.88,34.708 60.59,32.889 60.59,30.07C60.59,27.244 58.88,25.432 55.88,23.669L17.325,1.218C15.903,0.377 14.385,0 12.994,0C9.627,0 6.819,2.304 6.819,6.783L6.819,53.357Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 840 B

9
assets/printicon.svg Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.722394,0,0,0.722394,5.9777,6.827083)">
<path d="M59.163,9.595L59.163,11.291L52.799,11.291L52.799,9.093C52.799,6.925 51.685,5.842 49.537,5.842L22.508,5.842C20.381,5.842 19.246,6.925 19.246,9.093L19.246,11.291L12.882,11.291L12.882,9.595C12.882,3.054 16.355,0.038 22.471,0.038L49.574,0.038C55.913,0.038 59.163,3.054 59.163,9.595Z" style="fill-rule:nonzero;"/>
<path d="M72.045,21.372L72.045,49.747C72.045,56.338 68.448,59.828 61.879,59.828L58.686,59.828L58.686,53.668L61.842,53.668C64.264,53.668 65.553,52.389 65.553,49.958L65.553,21.171C65.553,18.739 64.264,17.441 61.842,17.441L10.224,17.441C7.78,17.441 6.513,18.739 6.513,21.171L6.513,49.958C6.513,52.389 7.78,53.668 10.224,53.668L13.359,53.668L13.359,59.828L10.187,59.828C3.596,59.828 0,56.338 0,49.747L0,21.372C0,14.802 3.819,11.291 10.187,11.291L61.879,11.291C68.448,11.291 72.045,14.802 72.045,21.372ZM58.787,24.698C58.787,26.994 56.896,28.863 54.643,28.863C52.348,28.863 50.479,26.994 50.479,24.698C50.479,22.445 52.348,20.555 54.643,20.555C56.896,20.555 58.787,22.445 58.787,24.698Z" style="fill-rule:nonzero;"/>
<path d="M20.471,69.656L51.574,69.656C56.358,69.656 58.686,67.455 58.686,62.534L58.686,38.564C58.686,33.653 56.358,31.453 51.574,31.453L20.471,31.453C15.825,31.453 13.359,33.653 13.359,38.564L13.359,62.534C13.359,67.455 15.687,69.656 20.471,69.656ZM21.596,63.744C20.228,63.744 19.503,63.051 19.503,61.652L19.503,39.435C19.503,38.036 20.228,37.374 21.596,37.374L50.47,37.374C51.86,37.374 52.541,38.036 52.541,39.435L52.541,61.652C52.541,63.051 51.86,63.744 50.47,63.744L21.596,63.744ZM26.088,47.624L46.029,47.624C47.322,47.624 48.294,46.631 48.294,45.327C48.294,44.086 47.322,43.124 46.029,43.124L26.088,43.124C24.775,43.124 23.792,44.086 23.792,45.327C23.792,46.631 24.785,47.624 26.088,47.624ZM26.088,58.053L46.029,58.053C47.322,58.053 48.294,57.071 48.294,55.819C48.294,54.537 47.322,53.544 46.029,53.544L26.088,53.544C24.785,53.544 23.792,54.537 23.792,55.819C23.792,57.071 24.775,58.053 26.088,58.053Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

10
assets/righticon.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="16px" height="16px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.900664,0,0,0.900141,0,5.83179)">
<rect x="0" y="0" width="71.059" height="58.143" style="fill-opacity:0;"/>
<g transform="matrix(-1,0,0,1,71.0587,0)">
<path d="M0,29.061C0,30.199 0.474,31.533 1.689,32.681L26.424,55.923C28.054,57.447 29.405,58.123 31.083,58.123C33.494,58.123 35.262,56.329 35.262,53.949L35.262,42.08L60.317,42.08C65.141,42.08 67.966,39.332 67.966,34.565L67.966,23.645C67.966,18.878 65.141,16.119 60.317,16.119L35.262,16.119L35.262,4.307C35.262,1.927 33.494,0 31.031,0C29.376,0 28.216,0.679 26.424,2.359L1.678,25.422C0.442,26.595 0,27.875 0,29.061Z" style="fill:var(--th-textColor);fill-opacity:0.85;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

19
assets/scrollicon.svg Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 38" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M23.346,11.673C23.346,5.23 18.115,0 11.673,0C5.23,0 0,5.23 0,11.673L0,25.599C0,32.042 5.23,37.272 11.673,37.272C18.115,37.272 23.346,32.042 23.346,25.599L23.346,11.673ZM20.012,11.673L20.012,25.599C20.012,30.202 16.275,33.939 11.673,33.939C7.07,33.939 3.333,30.202 3.333,25.599L3.333,11.673C3.333,7.07 7.07,3.333 11.673,3.333C16.275,3.333 20.012,7.07 20.012,11.673Z"/>
<g transform="matrix(0.750364,0,0,0.789657,2.72781,1.99815)">
<path d="M14.145,11.613C14.145,10.447 13.148,9.5 11.921,9.5C10.693,9.5 9.697,10.447 9.697,11.613L9.697,14.724C9.697,15.891 10.693,16.838 11.921,16.838C13.148,16.838 14.145,15.891 14.145,14.724L14.145,11.613Z">
<animateTransform
attributeName="transform"
type="translate"
values="0,8; 0,0; 0,8"
dur="3s"
repeatCount="indefinite"
calcMode="spline"
keyTimes="0; 0.8; 1"
keySplines="0.25 0.1 0.25 1; 0.8 0.1 0.9 1"
/>
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

12
assets/shareicon.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(1.09489,0,0,0.731951,0,0)">
<rect x="0" y="0" width="58.453" height="87.438" style="fill:none;"/>
<g transform="matrix(0.788242,0,0,1.1791,6.26284,-5.93234)">
<path d="M19.578,35.125L14.438,35.125C10.359,35.125 8.063,37.422 8.063,41.5L8.063,64.734C8.063,68.828 10.359,71.125 14.438,71.125L43.828,71.125C47.906,71.125 50.203,68.828 50.203,64.734L50.203,41.5C50.203,37.422 47.906,35.125 43.828,35.125L38.672,35.125L38.672,27.063L44.266,27.063C53.234,27.063 58.266,32.063 58.266,41.047L58.266,65.188C58.266,74.156 53.234,79.188 44.266,79.188L14,79.188C5.016,79.188 0,74.156 0,65.188L0,41.047C0,32.063 5.016,27.063 14,27.063L19.578,27.063L19.578,35.125Z" style="fill-rule:nonzero;"/>
<path d="M32.423,14.141L32.75,18.578L32.75,49.219C32.75,51.125 31.141,52.766 29.125,52.766C27.109,52.766 25.516,51.125 25.516,49.219L25.516,18.578L25.851,14.122L29.125,9.438L32.423,14.141Z" style="fill-rule:nonzero;"/>
<path d="M18.172,21.328C19.078,21.328 19.938,20.953 20.547,20.297L24.375,16.234L29.125,9.438L33.891,16.234L37.703,20.297C38.313,20.953 39.156,21.328 40.063,21.328C41.703,21.328 43.188,20.094 43.188,18.328C43.188,17.406 42.859,16.734 42.234,16.109L31.984,6.281C31.047,5.359 30.109,5.031 29.125,5.031C28.156,5.031 27.234,5.359 26.266,6.281L16.031,16.109C15.422,16.734 15.063,17.406 15.063,18.328C15.063,20.094 16.531,21.328 18.172,21.328Z" style="fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

7
assets/tloading.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 28 28" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.416638,0,0,0.416638,-39.1594,-8.54373)">
<path d="M120.504,85.859C119.819,85.859 119.259,85.626 118.823,85.159C118.387,84.692 118.17,84.148 118.17,83.525L118.17,35.164L101.551,35.164C100.867,35.164 100.306,34.931 99.871,34.464C99.435,33.997 99.217,33.452 99.217,32.83L99.217,22.84C99.217,22.156 99.435,21.596 99.871,21.16C100.306,20.724 100.867,20.506 101.551,20.506L151.78,20.506C152.464,20.506 153.024,20.724 153.46,21.16C153.896,21.596 154.114,22.156 154.114,22.84L154.114,32.83C154.114,33.452 153.896,33.997 153.46,34.464C153.024,34.931 152.464,35.164 151.78,35.164L135.161,35.164L135.161,83.525C135.161,84.148 134.943,84.692 134.508,85.159C134.072,85.626 133.512,85.859 132.827,85.859L120.504,85.859Z" style="fill:rgb(50,217,178);fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

7
assets/usericon.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.800736,0,0,0.800736,5.569453,5.575699)">
<path d="M33,66C51.234,66 66.016,51.219 66.016,33C66.016,14.781 51.234,0 33,0C14.781,0 0,14.781 0,33C0,51.219 14.781,66 33,66ZM33,57.781C19.297,57.781 8.219,46.703 8.219,33C8.219,19.297 19.297,8.219 33,8.219C46.703,8.219 57.797,19.297 57.797,33C57.797,46.703 46.703,57.781 33,57.781ZM52.875,53.672L52.734,52.844C50,48.266 42.859,44.047 33,44.047C23.172,44.047 16.016,48.266 13.281,52.828L13.141,53.672C19.469,58.812 27.266,61.312 33,61.312C38.766,61.312 46.516,58.828 52.875,53.672ZM33,39.188C39.125,39.219 43.844,34 43.844,27.328C43.844,21.031 39.078,15.703 33,15.703C26.938,15.703 22.141,21.031 22.172,27.328C22.188,34 26.906,39.156 33,39.188Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

15
assets/visiticon.svg Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 69" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(1,0,0,1.078125,0,0)">
<rect x="0" y="0" width="64" height="64" style="fill:none;"/>
<g transform="matrix(0.696061,0,0,0.645622,11.286576,-0.178206)">
<g transform="matrix(1.294019,0,0,1.294019,-14.778264,-19.167191)">
<path d="M31.081,34.767C31.081,36.238 30.496,37.648 29.456,38.688C28.417,39.728 27.006,40.313 25.535,40.312C22.159,40.313 15.812,40.313 15.812,40.312C12.781,40.312 11.094,42.031 11.094,45.031L11.094,67.438C11.094,70.469 12.781,72.188 15.812,72.188L45.281,72.188C48.281,72.188 49.969,70.469 49.969,67.438L49.969,54.901C49.969,53.43 50.556,52.02 51.6,50.984C52.643,49.948 54.058,49.372 55.528,49.383C55.547,49.383 55.567,49.383 55.586,49.383C58.617,49.406 61.063,51.87 61.063,54.901C61.062,60.489 61.062,68.344 61.062,68.344C61.062,77.906 55.688,83.281 46.156,83.281L14.938,83.281C5.375,83.281 0,77.906 0,68.344L0,44.125C0,34.562 5.375,29.219 14.938,29.219L25.535,29.219C27.006,29.219 28.417,29.803 29.456,30.843C30.496,31.883 31.081,33.294 31.081,34.764C31.081,34.765 31.081,34.766 31.081,34.767Z" style="fill-rule:nonzero;"/>
</g>
<g transform="matrix(1.207015,1.207015,-1.207015,1.207015,40.4225,-38.802922)">
<path d="M34.93,17.059L35.281,20.75L35.281,50.219C35.281,52.719 33.219,54.906 30.531,54.906C27.844,54.906 25.781,52.719 25.781,50.219L25.781,20.75L26.144,17.039L25.063,18.875L22.25,21.844C21.469,22.656 20.438,23.156 19.219,23.156C17.25,23.156 15.25,21.625 15.25,19.344C15.25,18.187 15.688,17.344 16.438,16.562L26.625,6.844C27.906,5.625 29.219,5.156 30.531,5.156C31.844,5.156 33.188,5.625 34.438,6.844L44.625,16.562C45.406,17.344 45.812,18.187 45.812,19.344C45.812,21.625 43.812,23.156 41.844,23.156C40.625,23.156 39.594,22.656 38.812,21.844L36,18.875L34.93,17.059Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

8
assets/volume1icon.svg Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.958582,0,0,0.958582,1.071092,3.970044)">
<path d="M52.481,44.862C54.611,46.154 57.226,45.648 58.547,43.785C61.407,39.793 63.107,34.66 63.107,29.21C63.107,23.76 61.407,18.634 58.547,14.587C57.226,12.771 54.611,12.235 52.481,13.558C50.173,14.958 49.628,17.69 51.528,20.953C52.982,23.262 53.798,26.182 53.798,29.21C53.798,32.238 52.975,35.126 51.528,37.467C49.635,40.713 50.173,43.454 52.481,44.862Z" style="fill-rule:nonzero;"/>
<path d="M35.812,58.451C39.021,58.451 41.444,56.027 41.444,52.826L41.444,5.724C41.444,2.523 39.021,0.031 35.798,0.031C33.804,0.031 32.231,0.776 30.143,2.737L17.826,14.138C17.662,14.278 17.5,14.372 17.307,14.372L8.752,14.372C3.093,14.372 0,17.599 0,23.484L0,35.013C0,40.905 3.093,44.149 8.752,44.149L17.283,44.149C17.476,44.149 17.638,44.171 17.802,44.311L30.143,55.814C32.122,57.666 33.723,58.451 35.812,58.451Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

9
assets/volume2icon.svg Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 80 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.958582,0,0,0.958582,1.248621,3.970044)">
<path d="M67.871,53.854C70.078,55.113 72.757,54.569 74.15,52.402C78.436,45.852 80.852,37.74 80.852,29.21C80.852,20.679 78.444,12.553 74.15,5.993C72.757,3.851 70.078,3.307 67.871,4.566C65.378,5.968 65.095,8.964 66.741,11.678C69.813,16.658 71.567,22.792 71.567,29.21C71.567,35.627 69.799,41.723 66.741,46.718C65.102,49.431 65.378,52.452 67.871,53.854Z" style="fill-rule:nonzero;"/>
<path d="M52.489,44.862C54.618,46.154 57.233,45.648 58.554,43.785C61.438,39.793 63.114,34.66 63.114,29.21C63.114,23.76 61.438,18.634 58.554,14.587C57.233,12.771 54.618,12.235 52.489,13.558C50.181,14.958 49.635,17.69 51.559,20.953C53.013,23.262 53.806,26.182 53.806,29.21C53.806,32.238 52.982,35.126 51.559,37.467C49.643,40.713 50.181,43.454 52.489,44.862Z" style="fill-rule:nonzero;"/>
<path d="M35.812,58.451C39.021,58.451 41.444,56.027 41.444,52.826L41.444,5.724C41.444,2.523 39.021,0.031 35.798,0.031C33.804,0.031 32.238,0.783 30.143,2.737L17.826,14.138C17.662,14.278 17.5,14.372 17.307,14.372L8.752,14.372C3.093,14.372 0,17.599 0,23.484L0,35.013C0,40.905 3.093,44.149 8.752,44.149L17.283,44.149C17.476,44.149 17.638,44.171 17.802,44.311L30.143,55.814C32.122,57.666 33.723,58.451 35.812,58.451Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

10
assets/volume3icon.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 94 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.934362,0,0,0.934362,0.9557,-0.120166)">
<path d="M83.147,67.962C85.427,69.269 88.2,68.598 89.569,66.345C95.229,57.102 98.558,46.187 98.558,34.368C98.558,22.566 95.207,11.642 89.569,2.415C88.2,0.131 85.427,-0.508 83.147,0.798C80.742,2.184 80.371,5.109 81.955,7.775C86.56,15.432 89.264,24.552 89.264,34.368C89.264,44.177 86.56,53.335 81.955,60.986C80.371,63.651 80.742,66.553 83.147,67.962Z" style="fill-rule:nonzero;"/>
<path d="M67.871,58.941C70.078,60.199 72.757,59.656 74.15,57.489C78.436,50.939 80.852,42.827 80.852,34.297C80.852,25.766 78.444,17.64 74.15,11.08C72.757,8.938 70.078,8.394 67.871,9.653C65.378,11.055 65.095,14.051 66.741,16.765C69.813,21.745 71.567,27.879 71.567,34.297C71.567,40.714 69.799,46.81 66.741,51.804C65.102,54.518 65.378,57.539 67.871,58.941Z" style="fill-rule:nonzero;"/>
<path d="M52.481,49.949C54.611,51.241 57.226,50.735 58.547,48.872C61.407,44.88 63.107,39.746 63.107,34.297C63.107,28.847 61.407,23.721 58.547,19.674C57.226,17.858 54.611,17.322 52.481,18.645C50.173,20.045 49.628,22.777 51.528,26.04C52.982,28.349 53.798,31.268 53.798,34.297C53.798,37.325 52.975,40.213 51.528,42.553C49.635,45.8 50.173,48.541 52.481,49.949Z" style="fill-rule:nonzero;"/>
<path d="M35.812,63.538C39.021,63.538 41.444,61.114 41.444,57.913L41.444,10.811C41.444,7.61 39.021,5.118 35.798,5.118C33.804,5.118 32.231,5.862 30.143,7.823L17.826,19.225C17.662,19.365 17.5,19.459 17.307,19.459L8.752,19.459C3.093,19.459 0,22.686 0,28.57L0,40.1C0,45.992 3.093,49.236 8.752,49.236L17.283,49.236C17.476,49.236 17.638,49.258 17.802,49.398L30.143,60.901C32.122,62.753 33.723,63.538 35.812,63.538Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

18
assets/website2024.svg Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 200 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,-159)">
<g transform="matrix(1,0,0,1,0,159)">
<rect x="0" y="0" width="200" height="60" style="fill:rgb(17,17,17);"/>
<g transform="matrix(1.106526,0,0,1.106526,-20.844032,-174.897983)">
<path d="M35.15,182.671L39.376,182.671C39.376,182.09 39.433,181.505 39.547,180.915C39.661,180.324 39.853,179.786 40.122,179.299C40.392,178.812 40.749,178.418 41.194,178.118C41.64,177.817 42.184,177.667 42.826,177.667C43.779,177.667 44.561,177.962 45.172,178.553C45.783,179.143 46.089,179.967 46.089,181.023C46.089,181.686 45.939,182.277 45.638,182.795C45.338,183.313 44.965,183.779 44.52,184.193C44.074,184.608 43.582,184.986 43.044,185.328C42.505,185.67 41.997,186.006 41.521,186.338C40.588,186.98 39.703,187.612 38.864,188.233C38.025,188.855 37.294,189.533 36.673,190.269C36.051,191.004 35.559,191.838 35.196,192.771C34.834,193.703 34.653,194.801 34.653,196.065L50.626,196.065L50.626,192.273L40.34,192.273C40.878,191.528 41.5,190.875 42.204,190.316C42.909,189.756 43.634,189.233 44.38,188.746C45.126,188.259 45.866,187.767 46.602,187.27C47.337,186.773 47.995,186.219 48.575,185.607C49.155,184.996 49.622,184.297 49.974,183.51C50.326,182.722 50.502,181.78 50.502,180.682C50.502,179.625 50.3,178.672 49.896,177.823C49.492,176.973 48.948,176.258 48.265,175.678C47.581,175.098 46.788,174.653 45.887,174.342C44.986,174.031 44.038,173.876 43.044,173.876C41.738,173.876 40.583,174.098 39.578,174.544C38.574,174.989 37.74,175.611 37.077,176.408C36.414,177.206 35.916,178.138 35.585,179.205C35.253,180.272 35.108,181.427 35.15,182.671Z" style="fill:rgb(205,205,205);fill-rule:nonzero;"/>
<path d="M56.345,185.095C56.345,184.722 56.35,184.271 56.36,183.743C56.371,183.214 56.407,182.671 56.469,182.111C56.531,181.552 56.635,180.998 56.78,180.449C56.925,179.9 57.127,179.407 57.386,178.972C57.645,178.537 57.976,178.185 58.38,177.916C58.784,177.646 59.287,177.512 59.887,177.512C60.488,177.512 60.996,177.646 61.41,177.916C61.825,178.185 62.166,178.537 62.436,178.972C62.705,179.407 62.907,179.9 63.042,180.449C63.176,180.998 63.28,181.552 63.353,182.111C63.425,182.671 63.467,183.214 63.477,183.743C63.487,184.271 63.492,184.722 63.492,185.095C63.492,185.716 63.472,186.467 63.43,187.348C63.389,188.228 63.259,189.078 63.042,189.896C62.824,190.714 62.472,191.414 61.985,191.994C61.498,192.574 60.799,192.864 59.887,192.864C58.997,192.864 58.313,192.574 57.836,191.994C57.36,191.414 57.013,190.714 56.795,189.896C56.578,189.078 56.448,188.228 56.407,187.348C56.365,186.467 56.345,185.716 56.345,185.095ZM51.932,185.095C51.932,187.27 52.144,189.088 52.569,190.549C52.993,192.009 53.568,193.175 54.294,194.045C55.019,194.915 55.863,195.537 56.826,195.91C57.79,196.282 58.81,196.469 59.887,196.469C60.985,196.469 62.016,196.282 62.98,195.91C63.943,195.537 64.792,194.915 65.528,194.045C66.263,193.175 66.844,192.009 67.268,190.549C67.693,189.088 67.905,187.27 67.905,185.095C67.905,182.981 67.693,181.205 67.268,179.765C66.844,178.325 66.263,177.17 65.528,176.3C64.792,175.43 63.943,174.808 62.98,174.435C62.016,174.062 60.985,173.876 59.887,173.876C58.81,173.876 57.79,174.062 56.826,174.435C55.863,174.808 55.019,175.43 54.294,176.3C53.568,177.17 52.993,178.325 52.569,179.765C52.144,181.205 51.932,182.981 51.932,185.095Z" style="fill:rgb(205,205,205);fill-rule:nonzero;"/>
<path d="M69.708,182.671L73.934,182.671C73.934,182.09 73.991,181.505 74.105,180.915C74.219,180.324 74.411,179.786 74.68,179.299C74.95,178.812 75.307,178.418 75.752,178.118C76.198,177.817 76.742,177.667 77.384,177.667C78.337,177.667 79.119,177.962 79.73,178.553C80.342,179.143 80.647,179.967 80.647,181.023C80.647,181.686 80.497,182.277 80.196,182.795C79.896,183.313 79.523,183.779 79.078,184.193C78.632,184.608 78.14,184.986 77.602,185.328C77.063,185.67 76.555,186.006 76.079,186.338C75.146,186.98 74.261,187.612 73.422,188.233C72.583,188.855 71.852,189.533 71.231,190.269C70.609,191.004 70.117,191.838 69.754,192.771C69.392,193.703 69.211,194.801 69.211,196.065L85.184,196.065L85.184,192.273L74.898,192.273C75.436,191.528 76.058,190.875 76.762,190.316C77.467,189.756 78.192,189.233 78.938,188.746C79.684,188.259 80.424,187.767 81.16,187.27C81.895,186.773 82.553,186.219 83.133,185.607C83.713,184.996 84.18,184.297 84.532,183.51C84.884,182.722 85.06,181.78 85.06,180.682C85.06,179.625 84.858,178.672 84.454,177.823C84.05,176.973 83.506,176.258 82.823,175.678C82.139,175.098 81.346,174.653 80.445,174.342C79.544,174.031 78.596,173.876 77.602,173.876C76.296,173.876 75.141,174.098 74.136,174.544C73.132,174.989 72.298,175.611 71.635,176.408C70.972,177.206 70.474,178.138 70.143,179.205C69.811,180.272 69.666,181.427 69.708,182.671Z" style="fill:rgb(205,205,205);fill-rule:nonzero;"/>
<path d="M95.533,179.843L95.533,187.394L89.815,187.394L95.44,179.843L95.533,179.843ZM95.533,191.03L95.533,196.065L99.729,196.065L99.729,191.03L102.619,191.03L102.619,187.394L99.729,187.394L99.729,174.311L95.782,174.311L86.334,186.99L86.334,191.03L95.533,191.03Z" style="fill:rgb(205,205,205);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.187747,0,0,0.192256,-49.35516,0.781798)">
<path d="M1206.826,134.382L1124.214,216.993L1104.104,197.815L1187.511,114.408L1104.712,114.408L1104.712,86.958L1234.935,86.958L1234.935,216.851L1206.826,216.851L1206.826,134.382Z" style="fill:rgb(255,53,28);"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

26
assets/website2025.svg Normal file
View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 200 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,0,-75)">
<g transform="matrix(1,0,0,1,0,75)">
<clipPath id="_clip1">
<rect x="0" y="0" width="200" height="60"/>
</clipPath>
<g clip-path="url(#_clip1)">
<use xlink:href="#_Image2" x="0" y="0" width="200px" height="60px"/>
</g>
<g transform="matrix(0.299846,0,0,0.299846,-111.624041,-77.944483)">
<path d="M947.978,337.608L901.718,337.608C900.662,337.608 899.702,337.2 898.838,336.384C897.974,335.568 897.542,334.632 897.542,333.576L897.542,322.344C897.542,321.288 897.974,320.352 898.838,319.536C899.702,318.72 900.662,318.312 901.718,318.312L967.817,318.312C971.199,318.312 974.203,319.676 976.827,322.403C979.554,325.027 980.918,328.031 980.918,331.413L980.918,397.512C980.918,398.568 980.51,399.528 979.694,400.392C978.878,401.256 977.942,401.688 976.886,401.688L965.654,401.688C964.598,401.688 963.662,401.256 962.846,400.392C962.03,399.528 961.622,398.568 961.622,397.512L961.622,351.252L912.36,400.515C911.613,401.262 910.646,401.652 909.458,401.686C908.27,401.72 907.302,401.363 906.556,400.617L898.614,392.674C897.867,391.928 897.51,390.96 897.544,389.772C897.578,388.585 897.969,387.617 898.715,386.87L947.978,337.608Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.892936,0,0,0.892936,5.201431,-2.091505)">
<path d="M20.399,30.2C20.399,30.573 20.267,30.908 20.003,31.203C19.738,31.499 19.435,31.646 19.093,31.646L15.08,31.646C14.738,31.646 14.434,31.514 14.17,31.25C13.905,30.985 13.773,30.666 13.773,30.293L13.773,30.013C13.773,29.64 13.797,29.235 13.843,28.8C13.89,28.364 14.061,27.703 14.356,26.817C14.652,25.93 15.064,25.16 15.593,24.507C16.122,23.854 16.938,23.263 18.043,22.734C19.147,22.205 20.446,21.941 21.939,21.941C23.308,21.941 24.521,22.119 25.579,22.477C26.636,22.835 27.469,23.325 28.075,23.947C28.682,24.569 29.18,25.191 29.568,25.814C29.957,26.436 30.229,27.128 30.385,27.89C30.541,28.652 30.642,29.228 30.688,29.617C30.735,30.005 30.758,30.402 30.758,30.806C30.758,33.202 29.911,35.636 28.215,38.109C26.52,40.582 24.848,42.441 23.199,43.685L29.405,43.685C29.747,43.685 30.058,43.817 30.338,44.082C30.618,44.346 30.758,44.65 30.758,44.992L30.758,48.118C30.758,48.46 30.618,48.764 30.338,49.028C30.058,49.293 29.747,49.425 29.405,49.425L15.686,49.425C15.344,49.425 15.033,49.293 14.753,49.028C14.473,48.764 14.333,48.46 14.333,48.118L14.333,44.759C14.333,44.385 14.473,44.074 14.753,43.825C21.255,38.319 24.506,33.933 24.506,30.666C24.506,29.702 24.358,28.955 24.062,28.427C23.767,27.898 23.152,27.633 22.219,27.633C21.068,27.633 20.461,28.489 20.399,30.2Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M45.597,21.941C48.148,21.941 50.255,22.586 51.92,23.877C53.584,25.168 54.758,26.825 55.443,28.847C56.127,30.869 56.469,33.233 56.469,35.939C56.469,38.646 56.127,41.01 55.443,43.032C54.758,45.054 53.584,46.711 51.92,48.002C50.255,49.293 48.148,49.938 45.597,49.938C41.833,49.938 39.072,48.647 37.314,46.065C35.557,43.483 34.678,40.108 34.678,35.939C34.678,31.771 35.557,28.396 37.314,25.814C39.072,23.232 41.833,21.941 45.597,21.941ZM45.597,44.199C46.313,44.199 46.927,44.097 47.44,43.895C47.953,43.693 48.366,43.359 48.677,42.892C48.988,42.425 49.237,41.982 49.423,41.562C49.61,41.142 49.742,40.551 49.82,39.789C49.898,39.027 49.944,38.412 49.96,37.946C49.976,37.479 49.983,36.81 49.983,35.939C49.983,35.068 49.976,34.399 49.96,33.933C49.944,33.466 49.898,32.852 49.82,32.09C49.742,31.327 49.61,30.736 49.423,30.316C49.237,29.897 48.988,29.453 48.677,28.987C48.366,28.52 47.953,28.186 47.44,27.983C46.927,27.781 46.313,27.68 45.597,27.68C44.757,27.68 44.042,27.859 43.451,28.217C42.859,28.574 42.416,28.979 42.121,29.43C41.825,29.881 41.607,30.534 41.467,31.39C41.327,32.245 41.242,32.961 41.211,33.536C41.18,34.112 41.164,34.913 41.164,35.939C41.164,36.966 41.18,37.767 41.211,38.342C41.242,38.918 41.327,39.633 41.467,40.489C41.607,41.344 41.825,41.998 42.121,42.449C42.416,42.9 42.859,43.304 43.451,43.662C44.042,44.02 44.757,44.199 45.597,44.199Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M66.642,30.2C66.642,30.573 66.51,30.908 66.245,31.203C65.981,31.499 65.677,31.646 65.335,31.646L61.322,31.646C60.98,31.646 60.677,31.514 60.412,31.25C60.148,30.985 60.016,30.666 60.016,30.293L60.016,30.013C60.016,29.64 60.039,29.235 60.086,28.8C60.132,28.364 60.303,27.703 60.599,26.817C60.895,25.93 61.307,25.16 61.836,24.507C62.364,23.854 63.181,23.263 64.285,22.734C65.39,22.205 66.688,21.941 68.182,21.941C69.55,21.941 70.764,22.119 71.821,22.477C72.879,22.835 73.711,23.325 74.318,23.947C74.924,24.569 75.422,25.191 75.811,25.814C76.2,26.436 76.472,27.128 76.628,27.89C76.783,28.652 76.884,29.228 76.931,29.617C76.978,30.005 77.001,30.402 77.001,30.806C77.001,33.202 76.153,35.636 74.458,38.109C72.762,40.582 71.09,42.441 69.442,43.685L75.648,43.685C75.99,43.685 76.301,43.817 76.581,44.082C76.861,44.346 77.001,44.65 77.001,44.992L77.001,48.118C77.001,48.46 76.861,48.764 76.581,49.028C76.301,49.293 75.99,49.425 75.648,49.425L61.929,49.425C61.587,49.425 61.276,49.293 60.996,49.028C60.716,48.764 60.576,48.46 60.576,48.118L60.576,44.759C60.576,44.385 60.716,44.074 60.996,43.825C67.497,38.319 70.748,33.933 70.748,30.666C70.748,29.702 70.6,28.955 70.305,28.427C70.009,27.898 69.395,27.633 68.462,27.633C67.311,27.633 66.704,28.489 66.642,30.2Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M80.687,42.659C80.687,42.285 80.796,41.959 81.014,41.679C81.232,41.399 81.512,41.259 81.854,41.259L85.82,41.259C86.318,41.259 86.707,41.508 86.987,42.005C87.298,43.53 88.184,44.292 89.646,44.292C91.326,44.292 92.166,43.11 92.166,40.746C92.166,38.319 91.264,37.106 89.46,37.106C88.962,37.106 88.449,37.184 87.92,37.339C87.453,37.495 87.08,37.572 86.8,37.572L83.254,37.153C82.849,37.121 82.507,36.958 82.227,36.663C81.947,36.367 81.807,36.017 81.807,35.613L81.807,23.714C81.807,23.372 81.947,23.068 82.227,22.804C82.507,22.539 82.818,22.407 83.16,22.407L94.779,22.407C95.122,22.407 95.433,22.539 95.713,22.804C95.993,23.068 96.133,23.372 96.133,23.714L96.133,26.84C96.133,27.182 95.993,27.486 95.713,27.75C95.433,28.014 95.122,28.147 94.779,28.147L88.013,28.147L88.013,32.253C88.76,31.755 89.755,31.506 91,31.506C91.653,31.506 92.267,31.561 92.843,31.67C93.418,31.779 94.079,32.035 94.826,32.44C95.573,32.844 96.218,33.381 96.762,34.049C97.307,34.718 97.766,35.675 98.139,36.919C98.512,38.164 98.699,39.626 98.699,41.305C98.699,41.554 98.683,41.865 98.652,42.239C98.621,42.612 98.442,43.296 98.116,44.292C97.789,45.287 97.338,46.166 96.762,46.928C96.187,47.69 95.277,48.383 94.033,49.005C92.788,49.627 91.311,49.938 89.6,49.938C88.013,49.938 86.629,49.681 85.447,49.168C84.265,48.655 83.363,47.986 82.74,47.162C82.118,46.337 81.652,45.567 81.341,44.852C81.029,44.136 80.812,43.405 80.687,42.659Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</g>
<defs>
<image id="_Image2" width="200px" height="60px" xlink:href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAA8AMgDAREAAhEBAxEB/8QAGwAAAwEBAQEBAAAAAAAAAAAAAQIDBAAHBgX/xAAiEAABBAIBBQEBAAAAAAAAAAAAAQIDYQRRFAUREhNiFVL/xAAdAQACAwEAAwEAAAAAAAAAAAABAwIEBQAGCAkH/8QAHBEAAwEBAQEBAQAAAAAAAAAAAAECAxMSERQE/9oADAMBAAIRAxEAPwD5CNp+krA9sqZojaSWAqmaI2kuAmmaI2klgJpmmJoeAmma4mB4lazVGwlxKlmmJhLiU9DVEwksShqao2oHkjO1NMaIBykZ2yNMaEGZWyNDBbMrZF2dhbMrZFmi2ZOyKtIsy9UOioRMzVDIqETN1Qe6HFDRB7ocUdEDuh3wo2hVUJVpCqofhWpCqqEkhFE3KSSK9HhMbTXWJ9QKZeNpJYiqZojQlxE0zRGhLiJpmmJA8RNM1xIHiV6Zqj7HckVbZojVAOUinoaI3EGijoaI3C2jP1NEbhTRnamiN4toy9i7Hi2jK2LseLcmVsVbJZByZWyKJIRcmXqhkkB5MzVDewHkztUH2psHkz9ED2h8lHSQLLZ3kp3IFlD5KlyKsth8lepEWWyXkr1IjpU2SUiKk8VYiHk3E+m7pF2diXEU6RdnYlxFOkXYqHchNUXjch3hITVGhjyDkrXRdkgtyVbosySxbkqXRojksW5KOlF2SWLclDVl2S2Lcmdqy7JrFuTM1ZZk1kHBl7FWzWQcGXsUbPZFwZeyHSeyLgzNUMk6bB4M7VB5Fg8GdrJ3Is7wUNJO5Fh5lLSQLkWdzKdwKuQmw8yrUCrkWFZleoEXIsksyvUCOyLCoEVB5C2RDzPmj6PPYq2UHgW9irZrIuBb2Ktmsg4FvUsyeyDgTWpZk6bFuCtWpVuRZB5letSzMiyDzKl6FmZNkHmU9NCrMqyDyKOllW5abIPIoa0VbmJsg8jO1ZRuZZF4mbqyjc2yLxM7UZM6yPEzdUMmdYOJn6SFM/6O4mfpB3PsHAoaQdz7DwKV5gXqCf0dwKd5gXqCf0FYFW8hV6gn9B4FashV6gn9B4FesRF6hZLgIrER2f8AQVgIrE8zbMeVuD30f9BRs5FwQf8AQUbPZFwQf9A7ciyLgg9yjcmyLzFVuUbk2R5iK2KNyrI8xFbDty7IvIrXqUTLsi8ireo6ZlkeJTvQdM2yPEpaWOmdYOJS0oZM+wcCjow8+wcChoH9CzuBR0R36P0D85R0k79GzvzlPSQfo2H85TuAfpfR35ypeYF6l9B/OVryFXqX0H85WrIVepfQfzlesRV6l9BX84msRV6l9EvziKxEXqX0H84qsD5NJbNnye4j3GSaweAdx0msj4IPYZJ7B4IPYZJ7B4FvYdJ7IuBVbDJkWDmJrUZMmwcxFah5Ng5latA8qzuZWuw8qwcirdh5dncirdHcyzuRTtncyzuRUtgXMs7iU7AuZYeJVtA5v0dxKtyBc2w8Srcirm2HiV6gVc2w8SvUCrm2HiIrMVc2w8RNZirm/QeIp5Crm2HiKeQq5tkuIp5H5yTFryezD3D7rB4B3GSYHgD3Ck9g8EHsH32d4IPY7kWDmLeweRZ3MW9TuTZ3MTWoeTZ3MTWgOVZ3MRWh3Ks7kV6sHKsPIr1QFy7O5FamKuXYeRWtgXMsPIr0KuZYeRXpCrmWHiV6Qq5lh4iKkVc2w8RFQKuZZLiKqBVzLDxEuBVzLDxFPMVcyw8RbzFXMsPEW8xVzLDxFvIt5Lsh8R+5e2d5LsHw52w+anfCLtnebtnfAe2d5u2d8Iumd5u2d8RB0wK92w/EQdMCvds74hTpgWR2w+ULdMHsdsPlCapi+x2w+UKpsVZHbD5QmmKsj9h8oTTFWR+w+UIoVZH7JeUJoRZH7D5QihVkfsKlCqFWV+w+UJaEWV+w+ULaAsj9hUoU0Isr9klKFtCrK/YfKFtI5ZH7CpRBpCrI/YfKFtIVZH7D5RFyj//Z"/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

16
assets/website2026.svg Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 200 60" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g>
<rect x="0" y="0" width="200" height="60" style="fill:rgb(29,33,52);"/>
<g transform="matrix(0.299846,0,0,0.299846,-111.62485,-77.944732)">
<path d="M947.978,337.608L901.718,337.608C900.662,337.608 899.702,337.2 898.838,336.384C897.974,335.568 897.542,334.632 897.542,333.576L897.542,322.344C897.542,321.288 897.974,320.352 898.838,319.536C899.702,318.72 900.662,318.312 901.718,318.312L967.817,318.312C971.199,318.312 974.203,319.676 976.827,322.403C979.554,325.027 980.918,328.031 980.918,331.413L980.918,397.512C980.918,398.568 980.51,399.528 979.694,400.392C978.878,401.256 977.942,401.688 976.886,401.688L965.654,401.688C964.598,401.688 963.662,401.256 962.846,400.392C962.03,399.528 961.622,398.568 961.622,397.512L961.622,351.252L912.36,400.515C911.613,401.262 910.646,401.652 909.458,401.686C908.27,401.72 907.302,401.363 906.556,400.617L898.614,392.674C897.867,391.928 897.51,390.96 897.544,389.772C897.578,388.585 897.969,387.617 898.715,386.87L947.978,337.608Z" style="fill:rgb(51,217,178);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.666002,0,0,0.666002,8.358802,7.991143)">
<path d="M15.126,49.425C14.784,49.425 14.496,49.308 14.263,49.075C14.03,48.841 13.913,48.569 13.913,48.258L13.913,44.945C13.913,44.727 13.975,44.393 14.1,43.942C14.224,43.491 14.551,43.063 15.08,42.659L19.933,37.899C22.421,36.002 24.467,34.392 26.069,33.07C27.671,31.747 28.861,30.573 29.638,29.547C30.416,28.52 30.805,27.556 30.805,26.653C30.805,25.596 30.533,24.717 29.988,24.017C29.444,23.317 28.503,22.967 27.165,22.967C26.263,22.967 25.517,23.162 24.926,23.55C24.334,23.939 23.876,24.445 23.549,25.067C23.222,25.689 22.997,26.327 22.872,26.98C22.748,27.385 22.538,27.664 22.242,27.82C21.947,27.976 21.628,28.053 21.286,28.053L15.453,28.053C15.173,28.053 14.94,27.96 14.753,27.773C14.566,27.587 14.473,27.353 14.473,27.073C14.504,25.58 14.823,24.173 15.43,22.85C16.036,21.528 16.892,20.37 17.996,19.374C19.1,18.379 20.423,17.593 21.962,17.018C23.502,16.442 25.237,16.154 27.165,16.154C29.81,16.154 32.057,16.582 33.908,17.438C35.759,18.293 37.167,19.475 38.131,20.984C39.095,22.493 39.578,24.258 39.578,26.28C39.578,27.804 39.243,29.228 38.574,30.55C37.905,31.872 36.957,33.132 35.728,34.329C34.499,35.527 33.045,36.764 31.365,38.039L27.072,42.379L39.064,42.379C39.406,42.379 39.694,42.488 39.928,42.705C40.161,42.923 40.277,43.203 40.277,43.545L40.277,48.258C40.277,48.569 40.161,48.841 39.928,49.075C39.694,49.308 39.406,49.425 39.064,49.425L15.126,49.425Z" style="fill:rgb(51,217,178);fill-rule:nonzero;"/>
<path d="M58.149,49.891C55.909,49.891 53.973,49.573 52.34,48.935C50.707,48.297 49.346,47.418 48.257,46.298C47.168,45.178 46.344,43.872 45.784,42.379C45.224,40.886 44.897,39.283 44.804,37.572C44.773,36.733 44.749,35.776 44.734,34.703C44.718,33.63 44.718,32.541 44.734,31.436C44.749,30.332 44.773,29.344 44.804,28.473C44.897,26.762 45.231,25.168 45.807,23.69C46.383,22.213 47.222,20.922 48.327,19.817C49.431,18.713 50.8,17.85 52.433,17.228C54.066,16.605 55.972,16.294 58.149,16.294C60.358,16.294 62.279,16.605 63.912,17.228C65.545,17.85 66.906,18.713 67.995,19.817C69.084,20.922 69.924,22.213 70.515,23.69C71.106,25.168 71.448,26.762 71.541,28.473C71.572,29.344 71.596,30.332 71.611,31.436C71.627,32.541 71.627,33.63 71.611,34.703C71.596,35.776 71.572,36.733 71.541,37.572C71.448,39.283 71.114,40.886 70.538,42.379C69.963,43.872 69.138,45.178 68.065,46.298C66.992,47.418 65.639,48.297 64.005,48.935C62.372,49.573 60.42,49.891 58.149,49.891ZM58.149,43.125C59.767,43.125 60.941,42.604 61.672,41.562C62.403,40.52 62.784,39.112 62.815,37.339C62.878,36.437 62.917,35.488 62.932,34.493C62.948,33.497 62.948,32.502 62.932,31.506C62.917,30.511 62.878,29.593 62.815,28.753C62.784,27.042 62.403,25.658 61.672,24.6C60.941,23.543 59.767,22.998 58.149,22.967C56.563,22.998 55.404,23.543 54.673,24.6C53.942,25.658 53.545,27.042 53.483,28.753C53.483,29.593 53.475,30.511 53.46,31.506C53.444,32.502 53.444,33.497 53.46,34.493C53.475,35.488 53.483,36.437 53.483,37.339C53.545,39.112 53.95,40.52 54.696,41.562C55.443,42.604 56.594,43.125 58.149,43.125Z" style="fill:rgb(51,217,178);fill-rule:nonzero;"/>
<path d="M77.188,49.425C76.845,49.425 76.558,49.308 76.324,49.075C76.091,48.841 75.974,48.569 75.974,48.258L75.974,44.945C75.974,44.727 76.037,44.393 76.161,43.942C76.285,43.491 76.612,43.063 77.141,42.659L81.994,37.899C84.482,36.002 86.528,34.392 88.13,33.07C89.732,31.747 90.922,30.573 91.7,29.547C92.477,28.52 92.866,27.556 92.866,26.653C92.866,25.596 92.594,24.717 92.05,24.017C91.505,23.317 90.564,22.967 89.226,22.967C88.324,22.967 87.578,23.162 86.987,23.55C86.396,23.939 85.937,24.445 85.61,25.067C85.284,25.689 85.058,26.327 84.934,26.98C84.809,27.385 84.599,27.664 84.304,27.82C84.008,27.976 83.689,28.053 83.347,28.053L77.514,28.053C77.234,28.053 77.001,27.96 76.814,27.773C76.628,27.587 76.534,27.353 76.534,27.073C76.565,25.58 76.884,24.173 77.491,22.85C78.097,21.528 78.953,20.37 80.057,19.374C81.162,18.379 82.484,17.593 84.024,17.018C85.563,16.442 87.298,16.154 89.226,16.154C91.871,16.154 94.118,16.582 95.969,17.438C97.82,18.293 99.228,19.475 100.192,20.984C101.157,22.493 101.639,24.258 101.639,26.28C101.639,27.804 101.304,29.228 100.635,30.55C99.967,31.872 99.018,33.132 97.789,34.329C96.56,35.527 95.106,36.764 93.426,38.039L89.133,42.379L101.125,42.379C101.468,42.379 101.755,42.488 101.989,42.705C102.222,42.923 102.339,43.203 102.339,43.545L102.339,48.258C102.339,48.569 102.222,48.841 101.989,49.075C101.755,49.308 101.468,49.425 101.125,49.425L77.188,49.425Z" style="fill:rgb(51,217,178);fill-rule:nonzero;"/>
<path d="M119.837,49.891C117.068,49.891 114.642,49.394 112.558,48.398C110.473,47.403 108.848,46.026 107.682,44.269C106.515,42.511 105.932,40.497 105.932,38.226C105.932,37.261 106.079,36.305 106.375,35.356C106.67,34.407 107.036,33.505 107.472,32.65C107.907,31.794 108.343,31.032 108.778,30.363C109.214,29.694 109.571,29.158 109.851,28.753L118.064,17.694C118.219,17.539 118.422,17.344 118.671,17.111C118.919,16.878 119.277,16.761 119.744,16.761L125.95,16.761C126.23,16.761 126.463,16.862 126.65,17.064C126.836,17.267 126.93,17.508 126.93,17.788C126.93,17.912 126.914,18.029 126.883,18.138C126.852,18.246 126.805,18.332 126.743,18.394L120.444,26.98C120.63,26.918 120.856,26.879 121.12,26.863C121.385,26.848 121.626,26.84 121.844,26.84C123.243,26.902 124.628,27.213 125.997,27.773C127.365,28.333 128.61,29.095 129.73,30.06C130.849,31.024 131.744,32.183 132.413,33.536C133.081,34.889 133.416,36.39 133.416,38.039C133.416,40.217 132.856,42.2 131.736,43.989C130.616,45.777 129.037,47.208 127,48.282C124.962,49.355 122.575,49.891 119.837,49.891ZM119.744,43.172C120.646,43.172 121.486,42.978 122.264,42.589C123.041,42.2 123.671,41.632 124.153,40.886C124.636,40.139 124.877,39.237 124.877,38.179C124.877,37.09 124.643,36.18 124.177,35.449C123.71,34.718 123.088,34.166 122.31,33.793C121.533,33.42 120.677,33.233 119.744,33.233C118.811,33.233 117.947,33.42 117.154,33.793C116.361,34.166 115.731,34.718 115.264,35.449C114.798,36.18 114.564,37.09 114.564,38.179C114.564,39.237 114.805,40.139 115.288,40.886C115.77,41.632 116.4,42.2 117.177,42.589C117.955,42.978 118.811,43.172 119.744,43.172Z" style="fill:rgb(51,217,178);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 69 70" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(1.075138,0,0,1.091055,0,0)">
<rect x="0" y="0" width="64" height="64" style="fill:none;"/>
<g transform="matrix(0.855629,0,0,0.843146,0.424897,0.652153)">
<path d="M2.7,26.688C-0.05,26.688 -0.706,29.406 0.763,31.25L9.2,42C10.138,43.219 11.981,43.219 12.95,42L21.356,31.25C22.794,29.406 22.138,26.688 19.45,26.688L2.7,26.688ZM73.481,34.125C73.481,15.281 58.169,0 39.356,0C20.513,0 5.231,15.25 5.231,34.188C5.231,37.25 7.731,39.75 10.794,39.75C13.856,39.75 16.356,37.25 16.356,34.188C16.356,21.406 26.638,11.125 39.356,11.125C52.075,11.125 62.356,21.406 62.356,34.125C62.356,46.844 52.075,57.125 39.356,57.125C33.231,57.125 28.138,54.938 23.638,50.875C21.013,48.594 17.7,47.812 15.2,50.281C12.981,52.438 12.856,55.906 15.763,58.719C22.2,65.062 30.981,68.25 39.356,68.25C58.169,68.25 73.481,52.969 73.481,34.125Z" style="fill-rule:nonzero;"/>
<g transform="matrix(1.087052,0,0,1.087052,-0.496591,-0.773476)">
<path d="M31.763,20.663C31.773,18.041 33.91,15.921 36.531,15.932C39.153,15.942 41.273,18.078 41.263,20.7L41.22,31.63L46.226,38.538C47.764,40.661 47.289,43.633 45.166,45.171C43.043,46.71 40.071,46.235 38.533,44.112L32.618,35.948C32.026,35.133 31.71,34.15 31.714,33.143L31.763,20.663Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

38
eslint.config.js Normal file
View File

@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

33
index.html Normal file
View File

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/assets/favicon.svg" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="description" content="Tom Butcher's personal website." />
<link rel="apple-touch-icon" href="/assets/favicon192.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,300..900;1,300..900&display=swap"
rel="stylesheet"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<link rel="stylesheet" href="/fonts.css" />
<link rel="manifest" href="/manifest.json" />
<title>Tom Butcher</title>
</head>
<body class="tb-body">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="tb-root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

9
main.jsx Normal file
View File

@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>,
);

80
package.json Normal file
View File

@ -0,0 +1,80 @@
{
"name": "thehideout-ui",
"version": "1.0.0",
"type": "module",
"private": true,
"homepage": "https://thehideout.uk",
"dependencies": {
"@ant-design/icons": "^6.0.0",
"@marsidev/react-turnstile": "^1.3.1",
"@tanstack/react-query": "^5.90.7",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.9.1",
"antd": "^5.24.6",
"axios": "^1.6.0",
"blurhash": "^2.0.5",
"color-convert": "^3.1.2",
"dayjs": "^1.11.19",
"hamburger-react": "^2.5.2",
"keycloak-js": "^26.1.4",
"overlayscrollbars": "^2.12.0",
"overlayscrollbars-react": "^0.5.6",
"rc-slider": "^11.1.9",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-ga4": "^2.1.0",
"react-icons": "^5.5.0",
"react-particles": "^2.12.2",
"react-responsive": "^10.0.1",
"react-router-dom": "^7.5.0",
"react-scripts": "^5.0.1",
"react-scroll": "^1.9.3",
"react-turnstile": "^1.1.4",
"sass": "^1.86.3",
"simplebar": "^6.3.2",
"simplebar-react": "^3.3.2",
"slider": "^1.0.4",
"vite": "^6.2.5",
"vite-plugin-svgo": "^2.0.0",
"vite-plugin-svgr": "^4.5.0",
"web-vitals": "^4.2.4"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "npm run build && vite preview",
"deploy": "npm run build && wrangler pages deploy ./dist --skip-caching"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@eslint/js": "^9.24.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0"
}
}

15
public/assets/favicon.svg Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 1080 1080" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,30.2391,35.2466)">
<path d="M967.939,275.664C967.939,149.227 865.288,46.575 738.85,46.575L280.672,46.575C154.234,46.575 51.583,149.227 51.583,275.664L51.583,733.842C51.583,860.28 154.234,962.931 280.672,962.931L738.85,962.931C865.288,962.931 967.939,860.28 967.939,733.842L967.939,275.664Z" style="fill:rgb(235,235,235);"/>
</g>
<g id="Background">
<g transform="matrix(1.05469,0,0,1.05469,7.90838e-05,7.90838e-05)">
<path d="M1024,651L1024,1024L651,1024C665.243,1024 679.483,1024.01 693.726,1023.92C705.722,1023.85 717.716,1023.71 729.709,1023.38C755.843,1022.68 782.203,1021.14 808.047,1016.49C834.264,1011.78 858.664,1004.09 882.484,991.963C905.897,980.046 927.321,964.474 945.898,945.898C964.474,927.321 980.046,905.897 991.963,882.484C1004.09,858.664 1011.78,834.264 1016.49,808.047C1021.14,782.203 1022.68,755.843 1023.38,729.709C1023.71,717.716 1023.85,705.722 1023.92,693.726C1024.01,679.483 1024,665.243 1024,651ZM1024,0L1024,0L1024,1024L0,1024L0,1024L373,1024C358.757,1024 344.517,1024.01 330.274,1023.92C318.278,1023.85 306.284,1023.71 294.291,1023.38C268.158,1022.68 241.797,1021.14 215.954,1016.49C189.736,1011.78 165.336,1004.09 141.516,991.963C118.104,980.046 96.679,964.474 78.103,945.898C59.526,927.321 43.955,905.897 32.037,882.484C19.913,858.664 12.221,834.264 7.509,808.047C2.863,782.203 1.325,755.843 0.617,729.709C0.291,717.716 0.153,705.722 0.084,693.726C-0.001,680.242 -0,666.761 0,653.279L0,370.721C-0,357.239 -0.001,343.758 0.084,330.274C0.153,318.278 0.291,306.284 0.617,294.291C1.325,268.158 2.863,241.797 7.509,215.954C12.221,189.736 19.913,165.336 32.037,141.516C43.955,118.104 59.526,96.679 78.103,78.103C96.679,59.526 118.104,43.955 141.516,32.037C165.336,19.913 189.736,12.221 215.954,7.509C241.797,2.863 268.158,1.325 294.291,0.616C306.284,0.291 318.278,0.153 330.274,0.084C343.758,-0.001 357.239,-0 370.721,0L653.279,0C666.761,-0 680.242,-0.001 693.726,0.084C705.722,0.153 717.716,0.291 729.709,0.616C755.843,1.325 782.203,2.863 808.047,7.509C834.264,12.221 858.664,19.913 882.484,32.037C905.897,43.955 927.321,59.526 945.898,78.103C964.474,96.679 980.046,118.104 991.963,141.516C1004.09,165.336 1011.78,189.736 1016.49,215.954C1021.14,241.797 1022.68,268.158 1023.38,294.291C1023.71,306.284 1023.85,318.278 1023.92,330.274C1024.01,344.517 1024,358.757 1024,373L1024,0Z" style="fill:rgb(47,54,64);"/>
</g>
<g transform="matrix(6.47263,0,0,6.47263,-5539.29,-1790.15)">
<path d="M947.978,337.608L901.718,337.608C900.662,337.608 899.702,337.2 898.838,336.384C897.974,335.568 897.542,334.632 897.542,333.576L897.542,322.344C897.542,321.288 897.974,320.352 898.838,319.536C899.702,318.72 900.662,318.312 901.718,318.312L967.817,318.312C971.199,318.312 974.203,319.676 976.827,322.403C979.554,325.027 980.918,328.031 980.918,331.413L980.918,397.512C980.918,398.568 980.51,399.528 979.694,400.392C978.878,401.256 977.942,401.688 976.886,401.688L965.654,401.688C964.598,401.688 963.662,401.256 962.846,400.392C962.03,399.528 961.622,398.568 961.622,397.512L961.622,351.252L912.36,400.515C911.613,401.262 910.646,401.652 909.458,401.686C908.27,401.72 907.302,401.363 906.556,400.617L898.614,392.674C897.867,391.928 897.51,390.96 897.544,389.772C897.578,388.585 897.969,387.617 898.715,386.87L947.978,337.608Z" style="fill:rgb(51,217,178);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

6
public/assets/grain.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'>\
<filter id='n'>\
<feTurbulence type='fractalNoise' baseFrequency='1' numOctaves='6' />\
</filter>\
<rect width='100%' height='100%' filter='url(#n)' fill='white' fill-opacity='0' />\
</svg>

After

Width:  |  Height:  |  Size: 276 B

131
public/data/pages.json Normal file
View File

@ -0,0 +1,131 @@
{
"pages": [
{
"id": 1,
"slug": "home",
"name": "Home",
"content": [
{
"type": "title1",
"text": "turning properties into <i>unforgettable</i> stays."
},
{
"type": "divider"
},
{
"type": "paragraph",
"text": "we take the stress out of hosting and property management. from professional guest communication and seamless check-ins to maintenance and cleaning, we handle every detail so your property shines and your guests feel at home. enjoy peace of mind while maximising your property's potential. we make hosting effortless and rewarding."
}
],
"theme": "dark",
"image1": "https://images.pexels.com/photos/20666872/pexels-photo-20666872.jpeg",
"image2": null,
"showScroll": true
},
{
"id": 2,
"slug": "about",
"name": "About Us",
"content": [
{
"type": "title2",
"text": "Who we are"
},
{
"type": "paragraph",
"text": "We are a creative team dedicated to building amazing web experiences. Our passion for design and development drives everything we do."
},
{
"type": "divider"
},
{
"type": "title3",
"text": "What We Do"
},
{
"type": "paragraph",
"text": "From concept to completion, we craft digital solutions that not only meet your needs but exceed your expectations."
}
],
"theme": "light",
"image1": "https://images.pexels.com/photos/5825527/pexels-photo-5825527.jpeg",
"image2": "https://images.pexels.com/photos/8580720/pexels-photo-8580720.jpeg",
"showScroll": false
},
{
"id": 3,
"slug": "services",
"name": "Our Services",
"content": [
{
"type": "title1",
"text": "What We Offer"
},
{
"type": "title2",
"text": "Web Development"
},
{
"type": "paragraph",
"text": "Custom websites and web applications built with modern technologies and best practices."
},
{
"type": "title2",
"text": "Mobile Apps"
},
{
"type": "paragraph",
"text": "Native and cross-platform mobile applications for iOS and Android devices."
},
{
"type": "divider"
},
{
"type": "title3",
"text": "Ready to Get Started?"
},
{
"type": "title4",
"text": "Let's build something amazing together."
}
],
"theme": "dark",
"image1": "https://images.pexels.com/photos/5490353/pexels-photo-5490353.jpeg",
"image2": null,
"showScroll": false
},
{
"id": 4,
"slug": "contact",
"name": "Contact",
"theme": "light",
"image1": "https://images.pexels.com/photos/32168965/pexels-photo-32168965.jpeg",
"image2": "https://images.pexels.com/photos/20927256/pexels-photo-20927256.jpeg",
"showScroll": false
},
{
"id": 5,
"slug": "thank-you",
"title": "Thank You",
"content": "Thank you for visiting The Hideout. We hope you enjoyed the smooth scrolling experience and look forward to working with you soon!",
"theme": "dark",
"image1": "https://images.pexels.com/photos/14750394/pexels-photo-14750394.jpeg",
"image2": null,
"showScroll": false
}
],
"themes": [
{
"name": "dark",
"backgroundColor": "#830B0D",
"textColor": "#ffffff",
"logo": "/assets/logo-light.svg"
},
{
"name": "light",
"backgroundColor": "#ffffff",
"textColor": "#5E0809",
"logo": "/assets/logo-dark.svg"
}
]
}

18
public/manifest.json Normal file
View File

@ -0,0 +1,18 @@
{
"short_name": "Tom Butcher",
"name": "TOM BUTCHER",
"icons": [
{
"src": "https://cdn.tombutcher.work/favicon/favicon192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "https://cdn.tombutcher.work/favicon/favicon512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/",
"display": "standalone"
}

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Silent Check SSO</title>
</head>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>

119
readme.md Normal file
View File

@ -0,0 +1,119 @@
# 2026 tombutcher.work UI
This is the front-end web application for **tombutcher.work** ([tombutcher.work](https://tombutcher.work)), built with **React.js** and hosted on **Cloudflare Pages**. The website showcases my personal work.
## Tech Stack
- **React 19**: Modern JavaScript library for building user interfaces
- **Vite**: Fast build tool and development server
- **Cloudflare Pages**: Static site hosting with global CDN
- **Cloudflare Turnstile**: Bot protection for form submissions
- **Keycloak**: Authentication and authorization
## Features
- Dynamic content delivery: pages, blogs, projects, companies, media, and CV assets load at runtime via React Query from the configured `VITE_API_URL`, so updates ship without redeploys.
- Immersive navigation: smooth-scrolling landing sections, animated sub-pages, and deep-link routing keep transitions fast while preserving stateful context.
- Adaptive theming: global theme context syncs typography and color tokens across devices, reacting to breakpoint changes for desktop, tablet, and mobile layouts.
- Account-aware UI: Keycloak SSO bootstraps user sessions for gated experiences while keeping the rest of the site public-by-default.
- Engagement tooling: Cloudflare Turnstile-secured contact form and multi-version CV download menu (digital vs. print) streamline outreach and asset sharing.
## Prerequisites
Before getting started, make sure you have the following installed:
- [Node.js](https://nodejs.org/) (v18 or higher recommended)
- [npm](https://www.npmjs.com/) or [yarn](https://yarnpkg.com/)
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) (for deployment)
- [Git](https://git-scm.com/)
## Getting Started
### 1. Clone the Repository
Clone this repository to your local machine:
```bash
git clone https://git.tombutcher.work/tom/thehideout-ui.git
cd thehideout-ui
```
### 2. Install Dependencies
Install the necessary dependencies:
```bash
npm install
```
### 3. Configure Environment
Ensure any necessary environment variables are set up for:
- Cloudflare Turnstile site keys
- Keycloak configuration
- API endpoints
### 4. Running Locally
To start the development server locally:
```bash
npm run dev
```
The website will be available at `http://localhost:5173` (Vite default port).
### 5. Building for Production
To create a production build:
```bash
npm run build
```
The optimized build will be output to the `./dist` directory.
### 6. Preview Production Build
To preview the production build locally:
```bash
npm run preview
```
## Deployment
### Deploy to Cloudflare Pages
Deploy using Wrangler CLI:
```bash
npm run deploy
```
This will build the project and deploy it to Cloudflare Pages.
## Project Structure
```
thehideout-ui/
├── src/
│ ├── components/ # Reusable React components
│ ├── contexts/ # React context providers
│ ├── icons/ # Custom icon components
│ ├── utils/ # Utility functions
│ ├── App.jsx # Main application component
│ └── main.jsx # Application entry point
├── public/ # Static assets
├── dist/ # Production build output
└── assets/ # Additional assets
```
## Resources
- [React](https://react.dev/)
- [Vite](https://vite.dev/)
- [Ant Design](https://ant.design/)
- [Cloudflare Pages](https://developers.cloudflare.com/pages/)
- [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/)

838
src/App.jsx Normal file
View File

@ -0,0 +1,838 @@
import PropTypes from "prop-types";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { Alert } from "antd";
import { useMediaQuery } from "react-responsive";
import { useNavigate, useLocation } from "react-router-dom";
import { Element, scroller } from "react-scroll";
import axios from "axios";
import { useQuery } from "@tanstack/react-query";
import Page from "./components/Page";
import { ImageProvider, useImageContext } from "./contexts/ImageContext";
import LoadingModal from "./components/LoadingModal";
import { ActionProvider } from "./contexts/ActionContext";
import SubPage from "./components/SubPage";
import BlogPage from "./components/Blogs/BlogPage";
import ProjectPage from "./components/Projects/ProjectPage";
import ExperiencePage from "./components/Experience/ExperiencePage";
import { MenuProvider, useMenu } from "./contexts/MenuContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import {
SettingsProvider,
useSettingsContext,
} from "./contexts/SettingsContext";
import { BlogsProvider } from "./contexts/BlogsContext";
import { ProjectsProvider } from "./contexts/ProjectsContext";
import { CompaniesProvider } from "./contexts/CompaniesContext";
import { KeycloakProvider } from "./contexts/KeycloakContext";
import { VideoProvider } from "./contexts/VideoContext";
import { FileProvider } from "./contexts/FileContext";
import Header from "./components/Header";
import Footer from "./components/Footer";
import { AccountProvider, useAccount } from "./contexts/AccountContext";
const apiUrl = import.meta.env.VITE_API_URL;
// Component that handles image loading after API data is fetched
const AppContent = ({ pages, blogs, images, projects, companies, loading }) => {
const { loadImages } = useImageContext();
const [loadedOnce, setLoadedOnce] = useState(false);
const [currentPage, setCurrentPage] = useState({ pageType: "landingPage" });
const [currentSubPage, setCurrentSubPage] = useState({
pageType: "subPage",
name: "",
slug: "",
theme: "",
notionId: "",
});
const [nextPage, setNextPage] = useState(null);
const [currentTheme, setCurrentTheme] = useState();
const [currentBlog, setCurrentBlog] = useState(null);
const [currentProject, setCurrentProject] = useState(null);
const [currentCompany, setCurrentCompany] = useState(null);
const [currentPageIdx, setCurrentPageIdx] = useState(0);
const [nextPageIdx, setNextPageIdx] = useState(0);
const [blogVisible, setBlogVisible] = useState(false);
const [projectVisible, setProjectVisible] = useState(false);
const [experienceVisible, setExperienceVisible] = useState(false);
const [subPageVisible, setSubPageVisible] = useState(false);
const [menuToggled, setMenuToggled] = useState(false);
const [accountToggled, setAccountToggled] = useState(false);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [headerLarge, setHeaderLarge] = useState(false);
const [pageLoaded, setPageLoaded] = useState(false);
const locationRef = useRef();
const currentPageTypeRef = useRef(currentPage.pageType);
const previousPageRef = useRef(null);
const currentPageRef = useRef(currentPage);
const landingPages = useMemo(
() => pages.filter((page) => page.pageType == "landingPage"),
[pages]
);
const { menuVisible, setMenuVisible, setActiveSlug } = useMenu();
const { accountVisible, setAccountVisible } = useAccount();
const navigate = useNavigate();
const location = useLocation();
const settings = useSettingsContext();
const isMobile = useMediaQuery({ maxWidth: 800 });
const [skipAnimation, setSkipAnimation] = useState(true);
// Memoize theme lookups to prevent creating new objects on every render
const themeMap = useMemo(() => {
const map = new Map();
settings?.themes?.forEach((theme) => {
map.set(theme.name, theme);
});
return map;
}, [settings?.themes]);
useEffect(() => {
if (loading == false) {
setTimeout(() => {
setSkipAnimation(false);
console.log("skipAnimation", loading);
}, 500); // Reduced from 2000ms to 500ms
}
}, [loading]);
// Check for iOS webclip and set body className
useEffect(() => {
if (window.navigator.standalone === true) {
document.body.classList.add("tb-ios-webclip");
}
}, []);
// Initialize image objects (metadata only, no loading) when they become available
useEffect(() => {
if (!loadedOnce && images && images.length > 0) {
loadImages(images);
setLoadedOnce(true);
}
}, [images, loadImages, loadedOnce]);
// Handle page load and header animation
useEffect(() => {
if (!pageLoaded && pages.length > 0) {
setPageLoaded(true);
}
if (nextPageIdx == 0 && isMobile == true) {
setHeaderLarge(false);
const timer1 = setTimeout(() => {
setHeaderLarge(true);
const timer2 = setTimeout(() => {
setHeaderLarge(false);
}, 3000); // 3 second
return () => clearTimeout(timer2);
}, 1000); // 1s wait
return () => clearTimeout(timer1);
}
if (nextPageIdx == 0 && isMobile == false) {
setHeaderLarge(true);
return;
}
if (nextPageIdx != 0) {
setHeaderLarge(false);
return;
}
}, [pageLoaded, pages.length, nextPageIdx, isMobile]);
// Track mouse position for transform origin
useEffect(() => {
const handleMouseMove = (event) => {
setMousePosition({ x: event.clientX, y: event.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
const setPageTitle = useCallback((name) => {
document.title = name ? `${name} - Tom Butcher` : "Tom Butcher";
}, []);
const handleBlogsRoute = useCallback(() => {
const blogSlug = location.pathname.split("/blogs/")[1];
if (blogSlug != "") {
const blog = blogs.find((p) => p.slug === blogSlug);
if (blog) {
// Set previousPage when navigating to a blog
if (currentPageRef.current.pageType !== "subPage") {
previousPageRef.current = { ...currentPageRef.current };
}
setHeaderLarge(false);
if (currentPageTypeRef.current == "blogPage") {
setBlogVisible(false);
setTimeout(() => {
setCurrentBlog(blog);
setBlogVisible(true);
}, 100); // Reduced from 500ms to 100ms
} else {
setBlogVisible(false);
setCurrentBlog(blog);
// Small delay to ensure DOM updates before making visible
requestAnimationFrame(() => {
setBlogVisible(true);
});
}
currentPageTypeRef.current = "blogPage";
setPageTitle(blog.name);
} else {
navigate(settings.redirects["404"]);
setCurrentBlog(null); // blog not found
setBlogVisible(false);
}
return; // exit early since we're on a blog page
}
}, [
blogs,
location.pathname,
settings.redirects,
navigate,
currentPageRef,
previousPageRef,
setCurrentBlog,
setBlogVisible,
setPageTitle,
]);
const handleCompaniesRoute = useCallback(() => {
const companySlug = location.pathname.split("/experience/")[1];
if (companySlug != "") {
const company = companies.find((c) => c.slug === companySlug);
if (company) {
// Set previousPage when navigating to a company
if (currentPageRef.current.pageType !== "subPage") {
previousPageRef.current = { ...currentPageRef.current };
}
setHeaderLarge(false);
if (currentPageTypeRef.current == "companyPage") {
setExperienceVisible(false);
setTimeout(() => {
setCurrentCompany(company);
setExperienceVisible(true);
}, 100); // Reduced from 500ms to 100ms
} else {
setExperienceVisible(false);
setCurrentCompany(company);
// Small delay to ensure DOM updates before making visible
requestAnimationFrame(() => {
setExperienceVisible(true);
});
}
currentPageTypeRef.current = "companyPage";
setPageTitle(company.name);
} else {
navigate(settings.redirects["404"]);
setCurrentCompany(null); // company not found
setExperienceVisible(false);
}
return; // exit early since we're on a company page
}
}, [
companies,
location.pathname,
settings.redirects,
navigate,
currentPageRef,
previousPageRef,
setCurrentCompany,
setExperienceVisible,
setPageTitle,
]);
const handleProjectsRoute = useCallback(() => {
const projectSlug = location.pathname.split("/projects/")[1];
if (projectSlug != "") {
const project = projects.find((p) => p.slug === projectSlug);
if (project) {
// Set previousPage when navigating to a project
if (currentPageRef.current.pageType !== "subPage") {
previousPageRef.current = { ...currentPageRef.current };
}
setHeaderLarge(false);
if (currentPageTypeRef.current == "projectPage") {
setProjectVisible(false);
setTimeout(() => {
setCurrentProject(project);
setProjectVisible(true);
}, 100); // Reduced from 500ms to 100ms
} else {
setProjectVisible(false);
setCurrentProject(project);
// Small delay to ensure DOM updates before making visible
requestAnimationFrame(() => {
setProjectVisible(true);
});
}
currentPageTypeRef.current = "projectPage";
setPageTitle(project.name);
} else {
navigate(settings.redirects["404"]);
setCurrentProject(null); // project not found
setProjectVisible(false);
}
return; // exit early since we're on a project page
}
}, [
projects,
location.pathname,
settings.redirects,
navigate,
currentPageRef,
previousPageRef,
setCurrentProject,
setProjectVisible,
setPageTitle,
]);
// Handle direct URL navigation
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
locationRef.current = location.pathname;
if (Object.keys(settings).length == 0) {
return;
}
console.log("location.pathname", location.pathname);
// Check if the URL matches /blogs/:slug
if (location.pathname.startsWith("/blogs/")) {
handleBlogsRoute();
return;
}
// Check if the URL matches /projects/:slug
if (location.pathname.startsWith("/projects/")) {
handleProjectsRoute();
return;
}
// Check if the URL matches /companies/:slug
if (location.pathname.startsWith("/experience/")) {
handleCompaniesRoute();
return;
}
setBlogVisible(false);
setProjectVisible(false);
setExperienceVisible(false);
if (pages.length > 0 && location.pathname !== "/") {
const slug = location.pathname.slice(1); // Remove leading slash
const page = pages.find((p) => p.slug === slug);
if (page) {
// Set previousPage when navigating to a subpage
if (
page.pageType === "subPage" &&
currentPageRef.current.pageType !== "subPage"
) {
previousPageRef.current = { ...currentPageRef.current };
}
setNextPage(page);
setCurrentPage(page);
setNextPageIdx(pages.indexOf(page));
setCurrentPageIdx(pages.indexOf(page));
setPageTitle(page.name);
if (page.pageType === "landingPage") {
// Ensure DOM has rendered before attempting to scroll
setSubPageVisible(false);
requestAnimationFrame(() => {
scroller.scrollTo(slug, {
duration: 0,
delay: 0,
containerId: "app-container",
});
});
} else {
if (currentPage.pageType == page.pageType) {
setSubPageVisible(false);
setCurrentSubPage(page);
// Small delay to ensure DOM updates before making visible
// Use double requestAnimationFrame for Edge compatibility
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setSubPageVisible(true);
});
});
} else {
setSubPageVisible(false);
setCurrentSubPage(page);
// Small delay to ensure DOM updates before making visible
// Use double requestAnimationFrame for Edge compatibility
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setSubPageVisible(true);
});
});
}
}
} else {
navigate(settings.redirects["404"]);
}
} else if (
pages.length > 0 &&
location.pathname === "/" &&
settings?.redirects?.index != null
) {
console.log("settings.redirects", settings.redirects);
const indexPage = pages.find((p) => p.slug === settings.redirects.index);
// Default to index page
setNextPage(indexPage);
setCurrentPage(indexPage);
setNextPageIdx(0);
setCurrentPageIdx(0);
setPageTitle(indexPage.name);
navigate(`/${indexPage.slug}`, { replace: true });
}
}, [
location.pathname,
pages,
settings?.redirects?.index,
navigate,
settings,
blogs,
projects,
companies,
currentPage.pageType,
currentBlog,
currentProject,
currentCompany,
handleBlogsRoute,
handleProjectsRoute,
handleCompaniesRoute,
setPageTitle,
]);
// Set up scroll spy to update URL when scrolling
useEffect(() => {
if (currentPage.pageType == "subPage") {
return;
}
if (pages.length > 0) {
let scrollTimeout;
const handleScrollSpy = () => {
const container = document.getElementById("app-container");
if (!container) return;
const scrollTop = container.scrollTop;
const containerHeight = container.clientHeight;
const viewportCenter = scrollTop + containerHeight / 2;
// Find which page is currently in the center of the viewport
let currentPageIndex = 0;
let minDistance = Infinity;
pages.forEach((page, index) => {
const element = document.querySelector(`[data-name="${page.slug}"]`);
if (element) {
const elementTop = element.offsetTop;
//const elementBottom = elementTop + element.offsetHeight;
const elementCenter = elementTop + element.offsetHeight / 2;
const distance = Math.abs(viewportCenter - elementCenter);
if (distance < minDistance) {
minDistance = distance;
currentPageIndex = index;
setMenuVisible(false);
setAccountVisible(false);
setNextPage(page);
setNextPageIdx(index);
if (
blogVisible == false &&
projectVisible == false &&
experienceVisible == false
) {
setCurrentTheme(themeMap.get(page.theme));
}
}
}
});
// Debounce both current page state and URL updates to reduce frequency
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
if (pages[currentPageIndex] && pages[currentPageIndex].slug) {
// Update current page state for Images component
if (currentPageIdx !== currentPageIndex) {
setCurrentPage(pages[currentPageIndex]);
setNextPage(pages[currentPageIndex]);
setNextPageIdx(currentPageIndex);
setCurrentPageIdx(currentPageIndex);
setPageTitle(pages[currentPageIndex].name);
const newPath = `/${pages[currentPageIndex].slug}`;
if (locationRef.current !== newPath) {
navigate(newPath, { replace: true });
}
}
}
}, 100); // Debounce for 100ms
};
const container = document.getElementById("app-container");
if (container) {
container.addEventListener("scroll", handleScrollSpy, {
passive: true,
});
return () => {
container.removeEventListener("scroll", handleScrollSpy);
clearTimeout(scrollTimeout);
};
}
}
}, [
pages,
navigate,
currentPageIdx,
currentPage.pageType,
themeMap,
setMenuVisible,
setNextPage,
setNextPageIdx,
setCurrentTheme,
setBlogVisible,
setProjectVisible,
setExperienceVisible,
setPageTitle,
blogVisible,
projectVisible,
experienceVisible,
]);
// Update currentPageRef whenever currentPage changes
useEffect(() => {
currentPageRef.current = currentPage;
currentPageTypeRef.current = currentPage.pageType;
}, [currentPage]);
// Update menu active slug when current page changes
useEffect(() => {
if (setActiveSlug && currentPage?.slug) {
setActiveSlug(currentPage.slug);
}
}, [currentPage?.slug, setActiveSlug]);
// Set body background color to match current page's theme
useEffect(() => {
const theme =
blogVisible == true
? settings?.globalThemes?.blog
: projectVisible == true
? themeMap.get(currentProject?.theme) || settings?.globalThemes?.project
: experienceVisible == true
? themeMap.get(currentCompany?.theme) ||
settings?.globalThemes?.experience
: themeMap.get(currentPage?.theme) || settings?.globalThemes?.page;
setCurrentTheme(theme);
}, [
nextPage,
themeMap,
blogVisible,
projectVisible,
experienceVisible,
settings?.globalThemes?.blog,
settings?.globalThemes?.project,
settings?.globalThemes?.experience,
settings?.globalThemes?.page,
currentPage?.theme,
currentProject?.theme,
currentCompany?.theme,
]);
useEffect(() => {
setMenuToggled(
subPageVisible ||
menuVisible ||
blogVisible ||
projectVisible ||
experienceVisible
);
if (subPageVisible == true) {
setMenuVisible(false);
}
if (blogVisible == true) {
setMenuVisible(false);
}
if (projectVisible == true) {
setMenuVisible(false);
}
if (experienceVisible == true) {
setMenuVisible(false);
}
}, [
subPageVisible,
menuVisible,
setMenuVisible,
blogVisible,
projectVisible,
experienceVisible,
]);
useEffect(() => {
if (accountVisible == false) {
setAccountToggled(false);
}
}, [accountVisible, setAccountToggled]);
// Handle subpage close with smart navigation
const handleSubPageClose = () => {
currentPageTypeRef.current = "landingPage";
// Check if there's a previous page stored in ref
if (previousPageRef.current && previousPageRef.current.slug) {
// Navigate to the previous page's slug
navigate(`/${previousPageRef.current.slug}`);
} else {
// No previous page, redirect to home
navigate("/");
}
};
const handleMenuToggle = (toggled) => {
setMenuToggled(toggled);
if (
toggled == false &&
(subPageVisible == true ||
blogVisible == true ||
projectVisible == true ||
experienceVisible == true)
) {
handleSubPageClose();
return;
}
setMenuVisible(toggled);
};
const handleAccountToggle = (toggled) => {
setAccountToggled(toggled);
setAccountVisible(toggled);
};
useEffect(() => {
console.log("currentCompany", currentCompany);
}, [currentCompany]);
return (
<ThemeProvider currentTheme={currentTheme}>
<Header
large={headerLarge}
pageData={nextPage}
theme={currentTheme}
onMenuToggle={handleMenuToggle}
menuToggled={menuToggled}
isMobile={isMobile}
/>
<div
className={`tb-app-container ${
!isMobile ? "tb-app-container-desktop" : "tb-app-container-mobile"
} ${
blogVisible == false &&
projectVisible == false &&
subPageVisible == false &&
experienceVisible == false &&
landingPages.length > 0 &&
skipAnimation == false
? "tb-visible"
: "tb-hidden"
}
${skipAnimation ? "tb-skip-animation" : ""}
`}
id="app-container"
>
{landingPages.map((pageData, index) => (
<Element
key={pageData.slug}
name={pageData.slug}
className={
!isMobile
? "tb-page-wrapper"
: "tb-page-wrapper tb-page-wrapper-mobile"
}
data-name={pageData.slug}
>
<Page
pageData={pageData}
isSubPage={false}
id={index}
particlesVisible={
pageData.notionId == currentPage.notionId &&
subPageVisible == false
}
/>
</Element>
))}
</div>
<SubPage
visible={subPageVisible}
mousePosition={mousePosition}
skipAnimation={skipAnimation}
>
<Page
pageData={currentSubPage}
isSubPage={true}
id={currentPageIdx}
showClose={true}
showMenu={false}
onClose={handleSubPageClose}
particlesVisible={subPageVisible}
/>
</SubPage>
<SubPage
visible={blogVisible}
mousePosition={mousePosition}
skipAnimation={skipAnimation}
>
<BlogPage blogData={currentBlog} />
</SubPage>
<SubPage
visible={projectVisible}
mousePosition={mousePosition}
skipAnimation={skipAnimation}
>
<ProjectPage projectData={currentProject} />
</SubPage>
<SubPage
visible={experienceVisible}
mousePosition={mousePosition}
skipAnimation={skipAnimation}
>
<ExperiencePage companyData={currentCompany} />
</SubPage>
<Footer
pageData={nextPage}
theme={currentTheme}
showAccount={true}
showLinks={true}
onAccountToggle={handleAccountToggle}
accountToggled={accountToggled}
/>
</ThemeProvider>
);
};
AppContent.propTypes = {
images: PropTypes.shape({
length: PropTypes.number,
}),
pages: PropTypes.array.isRequired,
blogs: PropTypes.array.isRequired,
projects: PropTypes.array.isRequired,
companies: PropTypes.array.isRequired,
themes: PropTypes.shape({
find: PropTypes.func,
}),
loading: PropTypes.bool,
};
const defaultSettings = {
themes: [],
redirects: {},
branding: [],
};
const fetchContent = async () => {
const response = await axios.get(`${apiUrl}/content`);
return response.data;
};
const App = () => {
const actions = {};
const { data, isLoading, error, isError } = useQuery({
queryKey: ["content"],
queryFn: fetchContent,
refetchOnWindowFocus: false,
});
const contentArray = Array.isArray(data) ? data : [];
const contentObject = !Array.isArray(data) && data ? data : {};
const pages = contentObject.pages || contentArray;
const blogs = contentObject.blogs || contentArray;
const projects = contentObject.projects || contentArray;
const companies = contentObject.companies || contentArray;
const images = contentObject.images || contentArray;
const cvs = contentObject.cvs || contentArray;
const settings = {
...defaultSettings,
...(contentObject.settings || {}),
};
const errorMessage = isError
? axios.isAxiosError(error)
? error.response?.data?.message ||
error.message ||
"Failed to fetch pages"
: error instanceof Error
? error.message
: "Failed to fetch pages"
: null;
if (isError) {
console.error("Error fetching content:", error);
}
if (errorMessage) {
return (
<div className="tb-error-container">
<Alert
message="Error Loading Pages"
description={errorMessage}
type="error"
showIcon
/>
</div>
);
}
return (
<KeycloakProvider>
<SettingsProvider settings={settings}>
<BlogsProvider initialBlogs={blogs}>
<ProjectsProvider initialProjects={projects}>
<CompaniesProvider initialCompanies={companies}>
<ActionProvider onAction={actions}>
<ImageProvider>
<VideoProvider>
<FileProvider>
<AccountProvider>
<MenuProvider
pages={pages}
currentPageSlug=""
cvs={cvs}
>
<AppContent
pages={pages}
images={images}
blogs={blogs}
projects={projects}
companies={companies}
loading={isLoading}
/>
<LoadingModal visible={isLoading} />
</MenuProvider>
</AccountProvider>
</FileProvider>
</VideoProvider>
</ImageProvider>
</ActionProvider>
</CompaniesProvider>
</ProjectsProvider>
</BlogsProvider>
</SettingsProvider>
</KeycloakProvider>
);
};
export default App;

View File

@ -0,0 +1,23 @@
import PropTypes from "prop-types";
import { useBlogs } from "../../contexts/BlogsContext";
import BlogListItem from "./BlogListItem.jsx";
const BlogList = () => {
const { blogs } = useBlogs();
return (
<div className="tb-blog-list-container">
<div className="tb-blog-list">
{blogs.map((blog, i) => (
<BlogListItem blogData={blog} key={i} />
))}
</div>
</div>
);
};
BlogList.propTypes = {
blogs: PropTypes.array,
};
export default BlogList;

View File

@ -0,0 +1,29 @@
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
const BlogListItem = ({ blogData, key }) => {
const navigate = useNavigate();
return (
<div
className="tb-blog-list-item-wrapper"
onClick={() => {
navigate(`/blogs/${blogData.slug}`);
}}
>
<div className="tb-blog-list-item" key={key}>
<div className="tb-blog-list-item-content">
<h3 className="tb-blog-list-item-title">{blogData?.name}</h3>
<p className="tb-blog-list-item-subtitle">{blogData?.subTitle}</p>
<p className="tb-blog-list-item-date">{blogData?.date}</p>
</div>
</div>
</div>
);
};
BlogListItem.propTypes = {
key: PropTypes.any,
blogData: PropTypes.object,
};
export default BlogListItem;

View File

@ -0,0 +1,92 @@
import { useRef, memo, useMemo } from "react";
import PropTypes from "prop-types";
import { Layout } from "antd";
import ContentRenderer from "../ContentRenderer";
import { useMediaQuery } from "react-responsive";
import { useSettingsContext } from "../../contexts/SettingsContext";
import ShareButton from "../Buttons/ShareButton";
const { Content } = Layout;
const BlogPage = memo(({ blogData }) => {
const settings = useSettingsContext();
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
const contentRef = useRef(null);
const theme = useMemo(
() => settings?.globalThemes?.blog || settings?.themes[0],
[settings?.globalThemes?.blog, settings?.themes]
);
if (blogData == null) {
return null;
}
const blogHeader = (
<div className="tb-blog-header">
<div className="tb-blog-header-title">
<h1
className={`tb-blog-title ${isMobile ? "tb-blog-title-mobile" : ""}`}
>
{blogData?.name || "n/a"}
</h1>
<ShareButton blogData={blogData} theme={theme} />
</div>
<div className="tb-blog-header-meta">
<p className="tb-blog-subtitle">{blogData?.subTitle || "n/a"}</p>
<p className="tb-blog-date">{blogData?.date || "n/a"}</p>
</div>
</div>
);
return (
<div
className={
!isMobile
? "tb-page-container tb-blog-page"
: "tb-page-container tb-page-container-mobile tb-blog-page"
}
style={{
"--tb-backgroundColor": theme?.backgroundColor,
"--tb-textColor": theme?.textColor,
background: "var(--tb-backgroundColor)",
}}
>
<Content
className={`tb-page-content tb-blog-content ${
isLargeMobile ? " tb-page-content-mobile tb-blog-content-mobile" : ""
}`}
ref={contentRef}
>
<div className="tb-content-container-wrapper">
<div
className={`tb-content-container tb-content-align-left tb-content-justify-top ${
isFullHeight ? "tb-content-container-full-height" : ""
} tb-content-container-vscroll`}
style={{
gap: "20px",
}}
>
<ContentRenderer
content={blogData?.content}
verticalScroll={true}
gap={12}
firstElement={blogHeader}
/>
</div>
</div>
</Content>
</div>
);
});
BlogPage.displayName = "BlogPage";
BlogPage.propTypes = {
blogData: PropTypes.object.isRequired,
};
export default BlogPage;

View File

@ -0,0 +1,48 @@
import PropTypes from "prop-types";
import PersonIcon from "../../icons/PersonIcon";
import MenuButton from "./MenuButton";
const AccountButton = ({
theme = null,
showAccount = true,
onAccountToggle = () => {},
accountToggled = false,
}) => {
return (
<button
className={`tb-footer-button tb-footer-button-account${
showAccount == false ? " tb-hidden" : ""
}`}
onClick={() => {
onAccountToggle(!accountToggled);
}}
>
<div
className={`tb-footer-icon${
accountToggled == true ? " tb-hidden" : ""
}`}
>
<PersonIcon />
</div>
<div
className={`tb-footer-icon-close${
accountToggled == false ? " tb-hidden" : ""
}`}
>
<MenuButton isInverted={false} theme={theme} toggled={true} />
</div>
</button>
);
};
AccountButton.propTypes = {
theme: PropTypes.shape({
backgroundColor: PropTypes.any,
textColor: PropTypes.any,
}),
showAccount: PropTypes.bool,
onAccountToggle: PropTypes.func,
accountToggled: PropTypes.bool,
};
export default AccountButton;

View File

@ -0,0 +1,29 @@
import { Fade as Hamburger } from "hamburger-react";
import PropTypes from "prop-types";
const MenuButton = ({ isInverted = false, theme, toggled, onToggle }) => {
return (
<button className="tb-header-button tb-header-button-menu">
<Hamburger
toggled={toggled}
toggle={onToggle}
size={30}
rounded
color={
isInverted
? theme?.backgroundColor || "#ffffff"
: theme?.textColor || "#ffffff"
}
/>
</button>
);
};
MenuButton.propTypes = {
isInverted: PropTypes.bool,
theme: PropTypes.object,
toggled: PropTypes.bool,
onToggle: PropTypes.func,
};
export default MenuButton;

View File

@ -0,0 +1,54 @@
import { useState, useRef } from "react";
import PropTypes from "prop-types";
import ShareIcon from "../../icons/ShareIcon";
import SharePopupMenu from "../Menu/SharePopupMenu";
import MenuButton from "./MenuButton";
const ShareButton = ({ blogData, projectData, theme = null }) => {
const [sharePopupVisible, setSharePopupVisible] = useState(false);
const buttonRef = useRef(null);
const data = blogData || projectData;
const shareTitle = data?.title || data?.name || document.title;
const shareUrl = data?.url || window.location.href;
const handleClick = () => {
setSharePopupVisible(!sharePopupVisible);
};
return (
<>
<button ref={buttonRef} className="tb-share-button" onClick={handleClick}>
<div
className={`tb-share-button-icon${
sharePopupVisible == true ? " tb-hidden" : ""
}`}
>
<ShareIcon />
</div>
<div
className={`tb-share-button-close${
sharePopupVisible == false ? " tb-hidden" : ""
}`}
>
<MenuButton isInverted={false} theme={theme} toggled={true} />
</div>
</button>
<SharePopupMenu
isVisible={sharePopupVisible}
onClose={() => setSharePopupVisible(false)}
buttonRef={buttonRef}
url={shareUrl}
title={shareTitle}
/>
</>
);
};
ShareButton.propTypes = {
blogData: PropTypes.object,
projectData: PropTypes.object,
theme: PropTypes.object,
};
export default ShareButton;

View File

@ -0,0 +1,20 @@
import PropTypes from "prop-types";
import VisitIcon from "../../icons/VisitIcon";
const VisitButton = ({ url }) => {
return (
<button
className="tb-visit-button"
onClick={() => window.open(url, "_blank")}
>
Visit
<VisitIcon />
</button>
);
};
VisitButton.propTypes = {
url: PropTypes.string.isRequired,
};
export default VisitButton;

View File

@ -0,0 +1,622 @@
import ContactForm from "./Forms/ContactForm";
import ProjectCards from "./Projects/ProjectCards";
import BlogList from "./Blogs/BlogList";
import { useNavigate } from "react-router-dom";
import SimpleBar from "simplebar-react";
import Image from "./Image";
import { useRef, useState, useEffect } from "react";
import CompaniesList from "./Experience/CompaniesList";
import dayjs from "dayjs";
import RightIcon from "../icons/RightIcon";
import LeftIcon from "../icons/LeftIcon";
import Video from "./Video";
import VisitIcon from "../icons/VisitIcon";
const gradientHeight = 60;
const renderAnnotation = (textObject = {}, navigate) => {
if (textObject.bold == true) {
return <b>{renderAnnotation({ ...textObject, bold: false }, navigate)}</b>;
}
if (textObject.italic == true) {
return (
<i>{renderAnnotation({ ...textObject, italic: false }, navigate)}</i>
);
}
if (textObject.underline == true) {
return (
<u>{renderAnnotation({ ...textObject, underline: false }, navigate)}</u>
);
}
if (textObject.link != undefined) {
const { type, url } = textObject.link;
if (type == "internalLink") {
return (
<a
className="tb-link"
href={url}
onClick={(e) => {
e.preventDefault();
navigate(url);
}}
>
<span className="tb-link-text">
{renderAnnotation({ ...textObject, link: undefined }, navigate)}
</span>
</a>
);
}
if (type == "externalLink") {
return (
<a
className="tb-link"
href={url}
onClick={(e) => {
e.preventDefault();
window.open(url, "_blank");
}}
>
<span className="tb-link-text">
{renderAnnotation({ ...textObject, link: undefined }, navigate)}
</span>
<span className="tb-link-icon">
<VisitIcon />
</span>
</a>
);
}
}
return textObject.text;
};
const renderText = (text = [], navigate) => {
return text.map((item) => {
return renderAnnotation(item, navigate);
});
};
// Function to render different content types
const renderContentElement = (
element,
index,
navigate,
paragraphWidth,
pageIcon,
isFirstTitle1,
gap
) => {
const { type, text } = element;
switch (type) {
case "pageIcon":
// Don't render pageIcon standalone, it will be rendered with first title1
return null;
case "title1":
return (
<h1 key={index} className="tb-title">
{isFirstTitle1 && pageIcon && (
<span
className="tb-page-icon"
dangerouslySetInnerHTML={{ __html: pageIcon }}
/>
)}
{renderText(text, navigate)}
</h1>
);
case "title2":
return (
<h2 key={index} className="tb-title">
{renderText(text, navigate)}
</h2>
);
case "title3":
return (
<h3 key={index} className="tb-title">
{renderText(text, navigate)}
</h3>
);
case "title4":
return (
<h4 key={index} className="tb-title">
{renderText(text, navigate)}
</h4>
);
case "paragraph":
return (
<p
key={index}
className="tb-paragraph"
style={{ maxWidth: paragraphWidth }}
>
{renderText(text, navigate)}
</p>
);
case "divider":
return <hr key={index} className="tb-divider" />;
case "list": {
const { ordered, children = [] } = element;
const ListTag = ordered ? "ol" : "ul";
return (
<ListTag
key={index}
className={
ordered ? "tb-list tb-orderedList" : "tb-list tb-unorderedList"
}
>
{children.map((child, childIdx) =>
renderContentElement(
child,
childIdx,
navigate,
paragraphWidth,
null,
false,
gap
)
)}
</ListTag>
);
}
case "listItem": {
// Support for nested lists inside list items
const { text = [], children = [] } = element;
return (
<li key={index} className="tb-listItem">
{text.length > 0 && <span>{renderText(text, navigate)}</span>}
{children.length > 0 &&
children.map((child, childIdx) =>
renderContentElement(
child,
childIdx,
navigate,
paragraphWidth,
null,
false,
gap
)
)}
{/* If children are lists, they will be rendered here */}
</li>
);
}
case "button":
return (
<button
key={index}
onClick={() => {
const linkUrl = new URL(element.url, window.location.origin);
if (linkUrl.origin === window.location.origin) {
// Same origin navigate with React Router
navigate(linkUrl.pathname + linkUrl.search + linkUrl.hash);
} else {
// Different origin full redirect
window.location.href = element.url;
}
}}
className="tb-button"
dangerouslySetInnerHTML={{
__html: `${element?.icon || ""}${text}`,
}}
/>
);
case "blogs":
return <BlogList blogs={[]} />;
case "projects":
return <ProjectCards projects={[]} />;
case "companies":
return <CompaniesList companies={[]} />;
case "contactForm":
return <ContactForm />;
case "image":
return <Image src={element.url} alt={element.caption} />;
case "video":
return <Video src={element.url} alt={element.caption} />;
case "columnFlex": {
const { children = [] } = element;
return (
<div
key={index}
className="tb-column-flex"
style={{ gap: gap * 2 + "px" }}
>
{children.map((child, childIdx) =>
renderContentElement(
child,
childIdx,
navigate,
paragraphWidth,
null,
false,
gap
)
)}
</div>
);
}
case "columnFlexItem": {
const { width, children = [] } = element;
return (
<div
key={index}
className="tb-column-flex-item"
style={{ width: `calc(${width} - ${gap}px)`, gap: `${gap}px` }}
>
{children.map((child, childIdx) =>
renderContentElement(
child,
childIdx,
navigate,
paragraphWidth,
null,
false,
gap
)
)}
</div>
);
}
case "positionsTimeline": {
const { children = [] } = element;
return (
<>
<h1 className="tb-title">Positions</h1>
<div className="tb-positions-timeline">
{children.map((child, childIdx) =>
renderContentElement(
child,
childIdx,
navigate,
paragraphWidth,
null,
false,
gap
)
)}
</div>
</>
);
}
case "positionTimelineItem": {
const { name, duration, content = [] } = element;
return (
<div className="tb-position-timeline-item" key={index}>
<p className="tb-position-timeline-item-year">
{duration?.start
? dayjs(duration.start).format("MMM YYYY")
: "YEAR"}
</p>
<div className="tb-h-divider"></div>
<div className="tb-position-timeline-item-wrapper">
<h3 className="tb-position-timeline-item-name">{name}</h3>
<div className="tb-position-timeline-item-content">
{content.map((child, childIdx) =>
renderContentElement(
child,
childIdx,
navigate,
paragraphWidth,
null,
false,
gap
)
)}
</div>
</div>
</div>
);
}
case "callout": {
const { children = [], icon = undefined, text } = element;
return (
<div className="tb-callout" key={index}>
{icon != undefined && (
<div
className="tb-callout-icon-container"
dangerouslySetInnerHTML={{ __html: icon }}
></div>
)}
<div className="tb-h-divider"></div>
<div className="tb-callout-content" style={{ gap: `10px` }}>
{text && (
<p
className="tb-paragraph"
dangerouslySetInnerHTML={{ __html: text }}
style={{ maxWidth: paragraphWidth }}
/>
)}
{children.map((child, childIdx) =>
renderContentElement(
child,
childIdx,
navigate,
paragraphWidth,
null,
false,
gap
)
)}
</div>
</div>
);
}
default:
console.log("Unknown type:", type);
return (
<p
key={index}
className={`tb-${type}`}
dangerouslySetInnerHTML={{ __html: text }}
onClick={(e) => {
handleTextClick(e, navigate);
}}
/>
);
}
};
const ContentRenderer = ({
content,
paragraphWidth = "100%",
verticalScroll = false,
horizontalScroll = false,
scrollSnap = false,
firstElement = null,
gap = 0,
scrollDistance = 25,
align = "left",
justify = "top",
}) => {
const navigate = useNavigate();
const simpleBarRef = useRef(null);
const [isScrollAtTop, setIsScrollAtTop] = useState(true);
const [isScrollAtStart, setIsScrollAtStart] = useState(true);
const [isScrollAtEnd, setIsScrollAtEnd] = useState(false);
const scrollLeft = () => {
const scrollElement = simpleBarRef.current?.getScrollElement();
if (scrollElement) {
scrollElement.scrollTo({
left: scrollElement.scrollLeft - scrollDistance,
behavior: "smooth",
});
}
};
const scrollRight = () => {
const scrollElement = simpleBarRef.current?.getScrollElement();
if (scrollElement) {
scrollElement.scrollTo({
left: scrollElement.scrollLeft + scrollDistance,
behavior: "smooth",
});
}
};
// Handle scroll position detection
useEffect(() => {
if ((!verticalScroll && !horizontalScroll) || !simpleBarRef.current) return;
const handleScroll = () => {
const scrollElement = simpleBarRef.current?.getScrollElement();
if (scrollElement) {
if (verticalScroll) {
const scrollTop = scrollElement.scrollTop;
setIsScrollAtTop(scrollTop <= 0);
}
if (horizontalScroll) {
const scrollLeft = scrollElement.scrollLeft;
const scrollWidth = scrollElement.scrollWidth;
const clientWidth = scrollElement.clientWidth;
setIsScrollAtStart(scrollLeft <= 0);
setIsScrollAtEnd(scrollLeft + clientWidth >= scrollWidth - 1); // -1 for rounding errors
}
}
};
const scrollElement = simpleBarRef.current?.getScrollElement();
if (scrollElement) {
scrollElement.addEventListener("scroll", handleScroll);
// Check initial scroll position
handleScroll();
// Also listen for resize events to update scroll state
const resizeObserver = new ResizeObserver(() => {
handleScroll();
});
resizeObserver.observe(scrollElement);
return () => {
scrollElement.removeEventListener("scroll", handleScroll);
resizeObserver.disconnect();
};
}
}, [verticalScroll, horizontalScroll]);
// Handle both old format (string) and new format (array of objects)
const renderContent = () => {
if (!content) {
return (
<p className="tb-content-default" style={{ maxWidth: paragraphWidth }}>
Default content for this page.
</p>
);
}
// If content is a string (old format), render it as a paragraph
if (typeof content === "string") {
return (
<p className="tb-content-default" style={{ maxWidth: paragraphWidth }}>
{content}
</p>
);
}
// If content is an array (new format), render each element
if (Array.isArray(content)) {
// Find the pageIcon element
const pageIconElement = content.find((el) => el.type === "pageIcon");
const pageIcon = pageIconElement ? pageIconElement.icon : null;
// Track if we've rendered the first title1
let hasRenderedFirstTitle1 = false;
const renderedElements = content.map((element, index) => {
const isFirstTitle1 =
element.type === "title1" && !hasRenderedFirstTitle1;
if (isFirstTitle1) {
hasRenderedFirstTitle1 = true;
}
return renderContentElement(
element,
index,
navigate,
paragraphWidth,
pageIcon,
isFirstTitle1,
gap
);
});
// Filter out null elements (like pageIcon elements that return null)
const validRenderedElements = renderedElements.filter(
(element) => element !== null
);
// If verticalScroll is true, wrap elements from index 1 onwards in a scrollable div
if (
(verticalScroll || horizontalScroll) &&
validRenderedElements.length > 0
) {
let headerElement = firstElement;
let scrollElements = validRenderedElements;
if (firstElement === null) {
headerElement = validRenderedElements[0];
scrollElements = validRenderedElements.slice(1);
}
if (verticalScroll == true) {
return (
<>
<div
className={`tb-page-scroll-sticky ${
isScrollAtTop ? "tb-scroll-top" : ""
}`}
>
{headerElement}
</div>
<div
className="tb-page-scroll-wrapper"
style={{
marginTop: `-${gradientHeight}px`,
"--simplebar-track-margin-top": `${gradientHeight}px`,
"--tb-top-gradient-height": `${gradientHeight}px`,
"--tb-bottom-gradient-height": `calc(100% - ${gradientHeight}px)`,
}}
>
<div
className="tb-top-gradient"
style={{ height: `${gradientHeight + 30}px` }}
/>
<div
className="tb-bottom-gradient"
style={{ height: `${gradientHeight + 30}px` }}
/>
<SimpleBar
ref={simpleBarRef}
forceVisible="x"
className={`tb-page-vscroll ${
scrollSnap == true ? "tb-page-scroll-snap" : ""
}`}
id="content-scroll"
>
<i className="tb-page-scroll-start"></i>
<div
className={`tb-content-scroll tb-content-align-${align} tb-content-justify-${justify}`}
style={{
gap: `${gap}px`,
paddingTop: `${gradientHeight}px`,
}}
>
{scrollElements}
</div>
</SimpleBar>
</div>
</>
);
}
if (horizontalScroll == true) {
return (
<>
<div
className={`tb-page-scroll-sticky ${
isScrollAtTop ? "tb-scroll-top" : ""
}`}
>
{headerElement}
</div>
<div
className="tb-page-scroll-wrapper"
style={{
"--simplebar-track-margin-top": `${gradientHeight}px`,
"--tb-left-gradient-width": `${gradientHeight}px`,
"--tb-right-gradient-width": `calc(100% - ${gradientHeight}px)`,
}}
>
<div className="tb-left-gradient" />
<div className="tb-right-gradient" />
<button
className={`tb-scroll-btn tb-left ${
isScrollAtStart ? "tb-hidden" : ""
}`}
onClick={scrollLeft}
>
<LeftIcon />
</button>
<button
className={`tb-scroll-btn tb-right ${
isScrollAtEnd ? "tb-hidden" : ""
}`}
onClick={scrollRight}
>
<RightIcon />
</button>
<SimpleBar
ref={simpleBarRef}
forceVisible="y"
autoHide={false}
className={`tb-page-hscroll ${
scrollSnap == true ? "tb-page-scroll-snap" : ""
}`}
id="content-scroll"
>
<i className="tb-page-scroll-start"></i>
<div
className="tb-content-scroll"
style={{
gap: `${gap}px`,
}}
>
{scrollElements}
</div>
</SimpleBar>
</div>
</>
);
}
}
return renderedElements;
}
return <p className="tb-content-default">Invalid content format.</p>;
};
return renderContent();
};
export default ContentRenderer;

View File

@ -0,0 +1,22 @@
import PropTypes from "prop-types";
import CompaniesListItem from "./CompaniesListItem.jsx";
import { useCompanies } from "../../contexts/CompaniesContext";
const CompaniesList = () => {
const { companies } = useCompanies();
return (
<div className="tb-companies-list-container">
<div className="tb-companies-list">
{companies.map((company, i) => (
<CompaniesListItem companyData={company} key={i} />
))}
</div>
</div>
);
};
CompaniesList.propTypes = {
companies: PropTypes.array,
};
export default CompaniesList;

View File

@ -0,0 +1,39 @@
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import dayjs from "dayjs";
const CompaniesListItem = ({ companyData, key }) => {
const navigate = useNavigate();
return (
<div
className="tb-companies-list-item-wrapper"
onClick={() => {
navigate(`/experience/${companyData.slug}`);
}}
>
<div className="tb-companies-list-item" key={key}>
<div className="tb-companies-list-item-content">
<p className="tb-companies-list-item-year">
{companyData.duration?.start
? dayjs(companyData.duration.start).format("MMM YYYY")
: "YEAR"}
</p>
<div className="tb-h-divider" />
{companyData?.logo && (
<div
className="tb-companies-list-item-logo"
dangerouslySetInnerHTML={{ __html: companyData.logo }}
/>
)}
</div>
</div>
</div>
);
};
CompaniesListItem.propTypes = {
key: PropTypes.any,
companyData: PropTypes.object,
};
export default CompaniesListItem;

View File

@ -0,0 +1,154 @@
import { useRef, memo, useMemo } from "react";
import PropTypes from "prop-types";
import { Layout } from "antd";
import ContentRenderer from "../ContentRenderer";
import { useMediaQuery } from "react-responsive";
import { useSettingsContext } from "../../contexts/SettingsContext";
import ShareButton from "../Buttons/ShareButton";
import VisitButton from "../Buttons/VisitButton";
import dayjs from "dayjs";
const { Content } = Layout;
const ExperiencePage = memo(({ companyData }) => {
const settings = useSettingsContext();
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
const contentRef = useRef(null);
const theme = useMemo(
() =>
companyData?.theme != undefined
? settings.themes.find((theme) => theme.name === companyData.theme)
: settings.globalThemes.experience,
[settings.themes, settings.globalThemes.experience, companyData]
);
const startDate = useMemo(() => {
return companyData?.duration?.start
? dayjs(companyData.duration.start)
: null;
}, [companyData]);
const endDate = useMemo(() => {
return companyData?.duration?.end
? dayjs(companyData.duration.end)
: dayjs(Date.now());
}, [companyData]);
const { years, months } = useMemo(() => {
if (!startDate || !endDate) {
return { years: 0, months: 0 };
}
const totalMonths = endDate.diff(startDate, "month");
const years = Math.floor(totalMonths / 12);
const months = totalMonths % 12;
return { years, months };
}, [startDate, endDate]);
if (companyData == null) {
return null;
}
const companyHeader = (
<div className="tb-experience-header">
<div className="tb-experience-header-title">
{companyData?.logo ? (
<div
className="tb-experience-logo"
dangerouslySetInnerHTML={{ __html: companyData.logo }}
/>
) : (
<h1
className={`tb-experience-title ${
isMobile ? "tb-experience-title-mobile" : ""
}`}
>
{companyData?.name || "n/a"}
</h1>
)}
<div className="tb-experience-header-buttons">
{companyData?.externalLink && (
<VisitButton url={companyData?.externalLink} />
)}
<ShareButton blogData={companyData} theme={theme} />
</div>
</div>
<div className="tb-experience-header-meta">
<p className="tb-experience-date">
{companyData?.duration?.start
? dayjs(companyData.duration.start).format("MMMM YYYY")
: "START DATE"}
{" - "}
{companyData?.duration?.end
? dayjs(companyData.duration.end).format("MMMM YYYY")
: "Present"}
</p>
<p className="tb-experience-months-years">
{years > 0 && (
<>
{years} {years === 1 ? "year " : "years "}
</>
)}
{months > 0 && (
<>
{months} {months === 1 ? "month" : "months"}
</>
)}
</p>
</div>
</div>
);
return (
<div
className={
!isMobile
? "tb-page-container tb-experience-page"
: "tb-page-container tb-page-container-mobile tb-experience-page"
}
style={{
"--tb-textColor": theme?.textColor,
"--tb-backgroundColor": theme?.backgroundColor,
background: theme?.backgroundColor,
color: theme?.textColor,
}}
>
<Content
className={`tb-page-content tb-experience-content ${
isLargeMobile
? " tb-page-content-mobile tb-experience-content-mobile"
: ""
}`}
ref={contentRef}
>
<div className="tb-content-container-wrapper">
<div
className={`tb-content-container tb-content-align-left tb-content-justify-top ${
isFullHeight ? "tb-content-container-full-height" : ""
} tb-content-container-vscroll`}
style={{
gap: "20px",
}}
>
<ContentRenderer
content={companyData?.content}
verticalScroll={true}
gap={12}
firstElement={companyHeader}
/>
</div>
</div>
</Content>
</div>
);
});
ExperiencePage.displayName = "ExperiencePage";
ExperiencePage.propTypes = {
companyData: PropTypes.object.isRequired,
};
export default ExperiencePage;

66
src/components/Footer.jsx Normal file
View File

@ -0,0 +1,66 @@
import PropTypes from "prop-types";
import { useMediaQuery } from "react-responsive";
import LinkIcon from "../icons/LinkIcon";
import { useNavigate } from "react-router-dom";
import { useSettingsContext } from "../contexts/SettingsContext";
import AccountButton from "./Buttons/AccountButton";
const Footer = ({
pageData,
theme = null,
showAccount = true,
showLinks = false,
onAccountToggle = () => {},
accountToggled = false,
}) => {
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const navigate = useNavigate();
const settings = useSettingsContext();
return (
<>
<div
className={`tb-footer${isLargeMobile ? " tb-footer-mobile" : ""}`}
style={{
"--tb-textColor":
pageData?.invertHeader && isMobile
? theme?.backgroundColor
: theme?.textColor,
}}
>
<AccountButton
theme={theme}
showAccount={showAccount}
onAccountToggle={onAccountToggle}
accountToggled={accountToggled}
/>
<button
className={`tb-footer-button${
showLinks == false ? " tb-hidden" : ""
}`}
onClick={() => {
navigate(settings.redirects.links);
}}
>
<div className="tb-footer-icon">
<LinkIcon />
</div>
</button>
</div>
<div className="tb-bottom-gradient" />
</>
);
};
Footer.propTypes = {
pageData: PropTypes.object,
theme: PropTypes.shape({
backgroundColor: PropTypes.any,
textColor: PropTypes.any,
}),
showAccount: PropTypes.bool,
showLinks: PropTypes.bool,
onAccountToggle: PropTypes.func,
accountToggled: PropTypes.bool,
};
export default Footer;

View File

@ -0,0 +1,148 @@
import { useState, useRef } from "react";
import axios from "axios";
import CheckIcon from "../../icons/CheckIcon";
import Turnstile from "./Turnstile";
import { useNavigate } from "react-router-dom";
import { useSettingsContext } from "../../contexts/SettingsContext.jsx";
import LoadingIcon from "../../icons/LoadingIcon.jsx";
import { useEffect } from "react";
const apiUrl = import.meta.env.VITE_API_URL;
const turnstileKey = import.meta.env.VITE_TURNSTILE_KEY;
const ContactForm = () => {
const navigate = useNavigate();
const settings = useSettingsContext();
const turnstileRef = useRef(null); // Ref for Turnstile
const [formData, setFormData] = useState({
name: "",
email: "",
message: "",
});
const [token, setToken] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [loadTurnstile, setLoadTurnstile] = useState(false);
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
useEffect(() => {
if (turnstileKey && turnstileKey != "") {
setTimeout(() => {
setLoadTurnstile(true);
}, 500);
}
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
if (!token) {
alert("Please complete the CAPTCHA");
return;
}
try {
setLoading(true);
// Send formData + token to your backend using axios
const response = await axios.post(`${apiUrl}/contact`, {
...formData,
token,
});
console.log(settings);
navigate(`/${settings.redirects.contactFormComplete}`);
console.log("Form submitted:", response.data);
} catch (error) {
console.error("Error submitting form:", error);
const errorData = error.response.data;
setError(errorData);
if (errorData.code.startsWith("captcha-")) {
turnstileRef.current.reset();
}
} finally {
setLoading(false);
}
};
return (
<div className="tb-contact-form-wrapper">
<form onSubmit={handleSubmit} className="tb-form">
<div className="tb-form-input-wrapper">
<input
id="name"
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="tb-form-input"
placeholder="Your Name"
disabled={loading}
autoComplete="name"
/>
</div>
<div className="tb-form-input-wrapper">
<input
id="email"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className="tb-form-input"
placeholder="Email"
disabled={loading}
autoComplete="email"
/>
</div>
<div className="tb-form-input-wrapper">
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
rows="5"
className="tb-form-input"
placeholder="Write your message here..."
disabled={loading}
autoComplete="message"
></textarea>
</div>
{/* Cloudflare Turnstile */}
{loadTurnstile == true && (
<Turnstile
siteKey={turnstileKey}
theme="light"
ref={turnstileRef}
onVerify={(token) => setToken(token)}
/>
)}
<div className="tb-form-actions">
<button
type="submit"
className={"tb-button"}
style={{ marginTop: 0 }}
disabled={loading}
>
{loading ? <LoadingIcon /> : <CheckIcon />}
Send Message
</button>
{error && <p className="tb-form-error">{error.message}</p>}
</div>
</form>
</div>
);
};
export default ContactForm;

View File

@ -0,0 +1,186 @@
import {
useEffect,
useRef,
forwardRef,
useImperativeHandle,
useMemo,
} from "react";
import PropTypes from "prop-types";
import { useTheme } from "../../contexts/ThemeContext.jsx";
const Turnstile = forwardRef(
({ siteKey = null, onVerify, theme = "auto", size = "normal" }, ref) => {
const widgetRef = useRef(null);
const widgetIdRef = useRef(null);
const onVerifyRef = useRef(onVerify);
const { theme: themeContext } = useTheme();
useEffect(() => {
onVerifyRef.current = onVerify;
}, [onVerify]);
const isColorDark = (color) => {
if (typeof color !== "string" || color.length === 0) {
return false;
}
const hexToRgb = (hex) => {
let normalized = hex.replace("#", "");
if (normalized.length === 3 || normalized.length === 4) {
normalized = normalized
.split("")
.map((char) => char + char)
.join("");
}
if (normalized.length !== 6 && normalized.length !== 8) {
return null;
}
const bigint = Number.parseInt(normalized.slice(0, 6), 16);
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255,
};
};
const parseRgbString = (value) => {
const match = value.match(
/^rgba?\(\s*([0-9]+)\s*,\s*([0-9]+)\s*,\s*([0-9]+)(?:\s*,\s*(\d*\.?\d+))?\s*\)$/i
);
if (!match) {
return null;
}
return {
r: Number.parseInt(match[1], 10),
g: Number.parseInt(match[2], 10),
b: Number.parseInt(match[3], 10),
a: match[4] !== undefined ? Number.parseFloat(match[4]) : 1,
};
};
const toRgb = (value) => {
if (value.startsWith("#")) {
return hexToRgb(value);
}
if (value.toLowerCase().startsWith("rgb")) {
return parseRgbString(value);
}
return null;
};
const rgb = toRgb(color);
if (!rgb) {
return false;
}
const toLinear = (channel) => {
const channelNormalized = channel / 255;
return channelNormalized <= 0.03928
? channelNormalized / 12.92
: ((channelNormalized + 0.055) / 1.055) ** 2.4;
};
const luminance =
0.2126 * toLinear(rgb.r) +
0.7152 * toLinear(rgb.g) +
0.0722 * toLinear(rgb.b);
return luminance < 0.5;
};
const resolvedTheme = useMemo(() => {
const backgroundColor = themeContext?.backgroundColor;
if (!backgroundColor) {
if (typeof window === "undefined") {
return "light";
}
const computedBackground =
window
?.getComputedStyle(document.body)
?.getPropertyValue("background-color") ?? "";
return isColorDark(computedBackground) ? "dark" : "light";
}
return isColorDark(backgroundColor) ? "dark" : "light";
}, [theme, themeContext?.backgroundColor]);
// Expose reset function to parent via ref
useImperativeHandle(ref, () => ({
reset: () => {
if (window.turnstile && widgetIdRef.current !== null) {
window.turnstile.reset(widgetIdRef.current);
}
},
}));
useEffect(() => {
if (!widgetRef.current) {
return;
}
const cleanupWidget = () => {
if (window.turnstile && widgetIdRef.current !== null) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
};
const renderWidget = () => {
if (!window.turnstile) return;
cleanupWidget();
widgetIdRef.current = window.turnstile.render(widgetRef.current, {
sitekey: siteKey,
theme: resolvedTheme,
size,
callback: (token) => {
if (typeof onVerifyRef.current === "function") {
onVerifyRef.current(token);
}
},
});
};
if (!document.getElementById("cf-turnstile-script")) {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.id = "cf-turnstile-script";
script.async = true;
document.body.appendChild(script);
script.onload = renderWidget;
} else {
renderWidget();
}
return cleanupWidget;
}, [siteKey, resolvedTheme, size]);
return (
<div ref={widgetRef} data-theme={resolvedTheme} data-size={size}></div>
);
}
);
Turnstile.displayName = "Turnstile";
Turnstile.propTypes = {
siteKey: PropTypes.string.isRequired,
onVerify: PropTypes.func,
theme: PropTypes.string,
size: PropTypes.string,
};
export default Turnstile;

44
src/components/Header.jsx Normal file
View File

@ -0,0 +1,44 @@
import PropTypes from "prop-types";
import { useMediaQuery } from "react-responsive";
import HeaderLogo from "./HeaderLogo";
import MenuButton from "./Buttons/MenuButton";
const Header = ({
theme = null,
large = false,
showMenu = true,
onMenuToggle = () => {},
menuToggled = false,
}) => {
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
return (
<>
<div className={`tb-header${isLargeMobile ? " tb-header-mobile" : ""}`}>
<HeaderLogo large={large} />
{showMenu && (
<MenuButton
theme={theme}
onToggle={onMenuToggle}
toggled={menuToggled}
/>
)}
</div>
<div className="tb-top-gradient" />
</>
);
};
Header.propTypes = {
pageData: PropTypes.object,
theme: PropTypes.shape({
backgroundColor: PropTypes.any,
textColor: PropTypes.any,
}),
large: PropTypes.bool,
showMenu: PropTypes.bool,
visible: PropTypes.bool,
onMenuToggle: PropTypes.func,
menuToggled: PropTypes.bool,
};
export default Header;

View File

@ -0,0 +1,38 @@
import PropTypes from "prop-types";
import { useEffect } from "react";
import { useSettingsContext } from "../contexts/SettingsContext";
import LogoSvg from "../../assets/logo.svg?react";
import { useNavigate } from "react-router-dom";
const HeaderLogo = ({ large = false, visible = true }) => {
const settings = useSettingsContext();
const navigate = useNavigate();
useEffect(() => {}, [large, settings.branding]);
return (
<header className="tb-header-logo">
<div
alt="The Hideout Logo"
className={`tb-logo-image tb-logo-current ${
large ? "tb-logo-image-large" : ""
} ${!visible ? "tb-logo-image-hidden" : ""}`}
onClick={() => {
navigate("/");
}}
>
<LogoSvg />
</div>
</header>
);
};
HeaderLogo.propTypes = {
currentTheme: PropTypes.string,
large: PropTypes.bool,
themes: PropTypes.shape({
find: PropTypes.func,
}),
visible: PropTypes.bool,
};
export default HeaderLogo;

249
src/components/Image.jsx Normal file
View File

@ -0,0 +1,249 @@
import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react";
import { decode } from "blurhash";
import { useImageContext } from "../contexts/ImageContext";
import LoadingIcon from "../icons/LoadingIcon";
const Image = ({ src, alt, className, loading = "lazy", ...props }) => {
const { imageObjects, loadIndividualImage } = useImageContext();
const [blurhashCanvas, setBlurhashCanvas] = useState(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [currentImageObj, setCurrentImageObj] = useState(null);
const [blurhashDataUrl, setBlurhashDataUrl] = useState(null);
const [imageDataUrl, setImageDataUrl] = useState(null);
const [renderError, setRenderError] = useState(false);
const [showError, setShowError] = useState(false);
const processedSrcRef = useRef(null);
// Find the image object that matches the src
useEffect(() => {
if (src) {
const imageObj = imageObjects.find((img) => img.src === src);
setCurrentImageObj(imageObj || null);
// Reset processed state when src changes
if (processedSrcRef.current !== src) {
processedSrcRef.current = src;
setImageLoaded(false);
setBlurhashDataUrl(null);
setImageDataUrl(null);
}
} else {
setCurrentImageObj(null);
processedSrcRef.current = null;
setImageLoaded(false);
setBlurhashDataUrl(null);
setImageDataUrl(null);
}
}, [src, imageObjects]);
// Load image when component renders and image is unloaded
useEffect(() => {
if (src && currentImageObj && currentImageObj.loadingState === "unloaded") {
loadIndividualImage(src);
}
}, [src, currentImageObj, loadIndividualImage]);
// Generate blurhash canvas when blurhash is available
useEffect(() => {
if (
currentImageObj?.blurHash &&
!blurhashCanvas &&
processedSrcRef.current === src
) {
try {
const pixels = decode(currentImageObj.blurHash, 32, 32);
const canvas = document.createElement("canvas");
canvas.width = 32;
canvas.height = 32;
const ctx = canvas.getContext("2d");
const imageData = ctx.createImageData(32, 32);
imageData.data.set(pixels);
ctx.putImageData(imageData, 0, 0);
setBlurhashCanvas(canvas);
setBlurhashDataUrl(canvas.toDataURL());
} catch (error) {
console.error("Failed to decode blurhash:", error);
}
}
}, [currentImageObj?.blurHash, blurhashCanvas, src]);
// Convert image blob/object URL to base64 data URL (to match blurhash approach)
useEffect(() => {
const source = currentImageObj?.blob;
if (!source) {
setImageDataUrl(null);
return;
}
// Already a data URL string
if (typeof source === "string" && source.startsWith("data:")) {
setImageDataUrl(source);
return;
}
let isCancelled = false;
const blobToDataUrl = (blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
async function convert() {
try {
// If we already have a Blob instance
if (source instanceof Blob) {
const dataUrl = await blobToDataUrl(source);
if (!isCancelled) setImageDataUrl(dataUrl);
return;
}
// If we have a URL string (blob: or http/https), fetch and convert
if (typeof source === "string") {
const response = await fetch(source);
const blob = await response.blob();
const dataUrl = await blobToDataUrl(blob);
if (!isCancelled) setImageDataUrl(dataUrl);
return;
}
setImageDataUrl(null);
} catch (error) {
console.error("Failed to convert image to base64:", error);
if (!isCancelled) setImageDataUrl(null);
}
}
convert();
return () => {
isCancelled = true;
};
}, [currentImageObj?.blob]);
// Handle image load
const handleImageLoad = () => {
setImageLoaded(true);
};
// Handle image error
const handleImageRetry = () => {
setImageLoaded(false);
loadIndividualImage(src);
};
// Handle image error
const handleImageError = () => {
setImageLoaded(false);
setCurrentImageObj({ ...currentImageObj, loadingState: "error" });
};
const errorContent = (
<div className="tb-image-error-content">
<h3 className="tb-image-error-title">Failed to load image</h3>
<button
className="tb-button tb-image-error-button"
onClick={handleImageRetry}
>
Try again
</button>
</div>
);
useEffect(() => {
const isError = currentImageObj?.loadingState === "error";
if (isError) {
setRenderError(true);
requestAnimationFrame(() => {
setShowError(true);
});
} else if (renderError) {
setShowError(false);
}
}, [currentImageObj?.loadingState, renderError]);
useEffect(() => {
if (!showError && renderError) {
const timeout = setTimeout(() => {
setRenderError(false);
}, 300);
return () => clearTimeout(timeout);
}
return undefined;
}, [showError, renderError]);
// Render image container if image object exists
if (currentImageObj) {
return (
<div
className={`tb-image-container ${className || ""} ${
imageLoaded !== true ? "tb-image-container-loading" : ""
}`}
>
{imageLoaded == false &&
currentImageObj &&
currentImageObj.loadingState !== "error" && (
<div className="tb-image-loading-container">
<div
className={`tb-image-loading ${imageLoaded ? "tb-hidden" : ""}`}
>
<LoadingIcon />
</div>
</div>
)}
{renderError ? (
<div
className={`tb-image-error ${showError ? "" : "tb-hidden"}`}
aria-hidden={!showError}
>
{errorContent}
</div>
) : null}
<img
src={
blurhashDataUrl ||
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
}
alt={alt}
className="tb-image-blurhash"
style={{
opacity: blurhashCanvas && !imageLoaded ? 1 : 0,
transition: "opacity 0.2s ease-in-out",
pointerEvents: "none",
}}
/>
{imageDataUrl && (
<img
src={imageDataUrl}
alt={alt}
className={`tb-image-content ${className}`}
loading={loading}
onLoad={handleImageLoad}
onError={handleImageError}
style={{
opacity: imageLoaded ? 1 : 0,
...props.style,
}}
{...props}
/>
)}
</div>
);
}
// No src provided or no matching image object
return null;
};
Image.propTypes = {
alt: PropTypes.any,
className: PropTypes.string,
loading: PropTypes.string,
src: PropTypes.any,
style: PropTypes.object,
};
export default Image;

View File

@ -0,0 +1,58 @@
import PropTypes from "prop-types";
import { useEffect, useState, useRef } from "react";
import TLoadingSvg from "../../assets/tloading.svg?react";
import OLoadingSvg from "../../assets/oloading.svg?react";
import MLoadingSvg from "../../assets/mloading.svg?react";
import IconLoadingSvg from "../../assets/iconloading.svg?react";
const LoadingModal = ({ visible = true }) => {
const [show, setShow] = useState(visible);
const loadingIcons = [IconLoadingSvg, TLoadingSvg, OLoadingSvg, MLoadingSvg];
const [currentIconIndex, setCurrentIconIndex] = useState(0);
useEffect(() => {
let timeout;
if (visible) {
// show immediately
setShow(true);
} else {
// delay hiding by 50ms - reduced for faster transition
timeout = setTimeout(() => setShow(false), 50);
}
return () => clearTimeout(timeout);
}, [visible]);
const interval = useRef(null);
useEffect(() => {
if (visible) {
interval.current = setInterval(() => {
setCurrentIconIndex(
(prevIndex) => (prevIndex + 1) % loadingIcons.length
);
}, 500);
return () => clearInterval(interval.current);
} else {
clearInterval(interval.current);
}
}, [loadingIcons.length, visible]);
const CurrentIcon = loadingIcons[currentIconIndex];
return (
<div
className={`tb-loading-modal ${
show ? "tb-loading-modal-visible" : "tb-loading-modal-hidden"
}`}
>
<CurrentIcon />
</div>
);
};
LoadingModal.propTypes = {
visible: PropTypes.bool,
};
export default LoadingModal;

View File

@ -0,0 +1,202 @@
import { useState, useEffect, useRef, useMemo } from "react";
import PropTypes from "prop-types";
import { useMediaQuery } from "react-responsive";
import DigitalIcon from "../../icons/DigitalIcon";
import PrintIcon from "../../icons/PrintIcon";
import DownloadFileIcon from "../../icons/DownloadFileIcon";
import WebsiteSelectorIcon from "../../icons/WebsiteSelectorIcon";
import CVVersionsSelectorPopup from "./CVVersionsSelectorPopup";
const CVDownloadPopupMenu = ({
isVisible = false,
onClose = () => {},
buttonRef = null,
cvs = [],
}) => {
const [shouldRender, setShouldRender] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [position, setPosition] = useState({ top: 0, right: 0 });
const popupRef = useRef(null);
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const cvDigitalVersionsSelectorButtonRef = useRef(null);
const cvPrintVersionsSelectorButtonRef = useRef(null);
const [
cvDigitalVersionsSelectorPopupVisible,
setCvDigitalVersionsSelectorPopupVisible,
] = useState(false);
const [
cvPrintVersionsSelectorPopupVisible,
setCvPrintVersionsSelectorPopupVisible,
] = useState(false);
const cvDigitalVersions = useMemo(
() => cvs.filter((cv) => cv.type === "digital"),
[cvs]
);
const cvPrintVersions = useMemo(
() => cvs.filter((cv) => cv.type === "print"),
[cvs]
);
const handleDigitalVersionsSelectorButtonClick = () => {
setCvDigitalVersionsSelectorPopupVisible(
!cvDigitalVersionsSelectorPopupVisible
);
};
const handlePrintVersionsSelectorButtonClick = () => {
setCvPrintVersionsSelectorPopupVisible(
!cvPrintVersionsSelectorPopupVisible
);
};
useEffect(() => {
if (isVisible) {
setShouldRender(true);
setIsExiting(false);
// Calculate position based on button
const updatePosition = () => {
if (buttonRef?.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 10,
right: window.innerWidth - rect.right,
});
}
};
// Update position immediately and on resize
updatePosition();
window.addEventListener("resize", updatePosition);
return () => window.removeEventListener("resize", updatePosition);
} else if (shouldRender) {
setIsExiting(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
}, 800); // Match animation duration
return () => clearTimeout(timer);
}
}, [isVisible, shouldRender, buttonRef]);
// Handle click outside
useEffect(() => {
if (!shouldRender || isExiting) return;
const handleClickOutside = (event) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target) &&
buttonRef?.current &&
!buttonRef.current.contains(event.target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [shouldRender, isExiting, onClose, buttonRef]);
const handleDownload = (option) => {
// Handle download logic here
if (option.url) {
window.open(option.url, "_blank");
}
onClose();
};
if (!shouldRender) return null;
return (
<div
ref={popupRef}
className={`tb-cv-popup${isLargeMobile ? " tb-mobile-cv-popup" : ""} ${
isExiting ? "tb-menu-popup-animated-exit" : "tb-menu-popup-animated"
}`}
style={{
top: `${position.top}px`,
right: `${position.right}px`,
}}
>
<div
className={`tb-cv-popup-container${
isLargeMobile ? " tb-mobile-cv-popup-container" : ""
}`}
>
<div className="tb-cv-digital">
<div className="tb-cv-digital-icon">
<DigitalIcon />
</div>
<div className="tb-cv-description">
<h3 className="tb-cv-title">
Digital
<button
className="tb-button tb-menu-popup-button tb-cv-versions-button"
onClick={handleDigitalVersionsSelectorButtonClick}
>
<WebsiteSelectorIcon />
</button>
</h3>
<p className="tb-cv-subtitle">
Dark background, light text. Best for emailing.
</p>
</div>
<button className="tb-button tb-menu-popup-button tb-cv-download-button">
PDF
<DownloadFileIcon />
</button>
</div>
<div className="tb-cv-main-divider" />
<div className="tb-cv-print">
<div className="tb-cv-print-icon">
<PrintIcon />
</div>
<div className="tb-cv-description">
<h3 className="tb-cv-title">
Printable
<button
className="tb-button tb-menu-popup-button tb-cv-versions-button"
onClick={handlePrintVersionsSelectorButtonClick}
>
<WebsiteSelectorIcon />
</button>
</h3>
<p className="tb-cv-subtitle">
Light background, dark text. Best for printing.
</p>
</div>
<button className="tb-button tb-menu-popup-button tb-cv-download-button">
PDF
<DownloadFileIcon />
</button>
</div>
</div>
<CVVersionsSelectorPopup
isVisible={cvDigitalVersionsSelectorPopupVisible}
onClose={() => setCvDigitalVersionsSelectorPopupVisible(false)}
buttonRef={cvDigitalVersionsSelectorButtonRef}
options={cvDigitalVersions}
/>
<CVVersionsSelectorPopup
isVisible={cvPrintVersionsSelectorPopupVisible}
onClose={() => setCvPrintVersionsSelectorPopupVisible(false)}
buttonRef={cvPrintVersionsSelectorButtonRef}
options={cvPrintVersions}
/>
</div>
);
};
CVDownloadPopupMenu.propTypes = {
isVisible: PropTypes.bool,
onClose: PropTypes.func,
buttonRef: PropTypes.shape({
current: PropTypes.instanceOf(HTMLElement),
}),
cvs: PropTypes.array,
};
export default CVDownloadPopupMenu;

View File

@ -0,0 +1,140 @@
import { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useMediaQuery } from "react-responsive";
const CVVersionsSelectorPopup = ({
isVisible = false,
onClose = () => {},
buttonRef = null,
options = [],
}) => {
const [shouldRender, setShouldRender] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [position, setPosition] = useState({ top: 0, right: 0 });
const popupRef = useRef(null);
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
useEffect(() => {
if (isVisible) {
setShouldRender(true);
setIsExiting(false);
const updatePosition = () => {
if (buttonRef?.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 10,
right: window.innerWidth - rect.right,
});
}
};
updatePosition();
window.addEventListener("resize", updatePosition);
return () => window.removeEventListener("resize", updatePosition);
} else if (shouldRender) {
setIsExiting(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
}, 800);
return () => clearTimeout(timer);
}
}, [isVisible, shouldRender, buttonRef]);
useEffect(() => {
if (!shouldRender || isExiting) return undefined;
const handleClickOutside = (event) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target) &&
buttonRef?.current &&
!buttonRef.current.contains(event.target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [shouldRender, isExiting, onClose, buttonRef]);
const handleOptionSelect = (option) => {
if (typeof option.onClick === "function") {
option.onClick(option);
} else if (option.url) {
window.open(option.url, "_blank");
}
onClose();
};
if (!shouldRender || options.length === 0) return null;
return (
<div
ref={popupRef}
className={`tb-cv-selector-popup${
isLargeMobile ? " tb-mobile-cv-selector-popup" : ""
} ${
isExiting ? "tb-menu-popup-animated-exit" : "tb-menu-popup-animated"
}`}
style={{
top: `${position.top}px`,
right: `${position.right}px`,
}}
>
<div
className={`tb-cv-selector-popup-container${
isLargeMobile ? " tb-mobile-cv-selector-popup-container" : ""
}`}
>
<div className="tb-cv-selector-list">
{options.map((option, index) => (
<button
key={option.id || option.label || index}
type="button"
className="tb-button tb-menu-popup-button tb-cv-selector-option"
onClick={() => handleOptionSelect(option)}
>
{option.icon && (
<span className="tb-cv-selector-option-icon">
{option.icon}
</span>
)}
<span className="tb-cv-selector-option-label">
{option.label}
</span>
{option.trailingIcon && (
<span className="tb-cv-selector-option-trailing-icon">
{option.trailingIcon}
</span>
)}
</button>
))}
</div>
</div>
</div>
);
};
CVVersionsSelectorPopup.propTypes = {
isVisible: PropTypes.bool,
onClose: PropTypes.func,
buttonRef: PropTypes.shape({
current: PropTypes.instanceOf(HTMLElement),
}),
options: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
label: PropTypes.string.isRequired,
icon: PropTypes.node,
trailingIcon: PropTypes.node,
url: PropTypes.string,
onClick: PropTypes.func,
})
),
};
export default CVVersionsSelectorPopup;

View File

@ -0,0 +1,201 @@
import { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useMediaQuery } from "react-responsive";
import {
FaTwitter,
FaFacebookF,
FaLinkedinIn,
FaRedditAlien,
FaEnvelope,
FaLink,
} from "react-icons/fa";
const SharePopupMenu = ({
isVisible = false,
onClose = () => {},
buttonRef = null,
url = null,
title = null,
}) => {
const [shouldRender, setShouldRender] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [position, setPosition] = useState({ top: 0, right: 0 });
const [copied, setCopied] = useState(false);
const popupRef = useRef(null);
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
// Get current page URL if not provided
const shareUrl = url || window.location.href;
const shareTitle = title || document.title;
useEffect(() => {
if (isVisible) {
setShouldRender(true);
setIsExiting(false);
// Calculate position based on button
const updatePosition = () => {
if (buttonRef?.current) {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 10,
right: window.innerWidth - rect.right,
});
}
};
// Update position immediately and on resize
updatePosition();
window.addEventListener("resize", updatePosition);
return () => window.removeEventListener("resize", updatePosition);
} else if (shouldRender) {
setIsExiting(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
}, 800); // Match animation duration
return () => clearTimeout(timer);
}
}, [isVisible, shouldRender, buttonRef]);
// Handle click outside
useEffect(() => {
if (!shouldRender || isExiting) return;
const handleClickOutside = (event) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target) &&
buttonRef?.current &&
!buttonRef.current.contains(event.target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [shouldRender, isExiting, onClose, buttonRef]);
const shareOptions = [
{
name: "Twitter",
url: `https://twitter.com/intent/tweet?url=${encodeURIComponent(
shareUrl
)}&text=${encodeURIComponent(shareTitle)}`,
icon: FaTwitter,
},
{
name: "Facebook",
url: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
shareUrl
)}`,
icon: FaFacebookF,
},
{
name: "LinkedIn",
url: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(
shareUrl
)}`,
icon: FaLinkedinIn,
},
{
name: "Reddit",
url: `https://reddit.com/submit?url=${encodeURIComponent(
shareUrl
)}&title=${encodeURIComponent(shareTitle)}`,
icon: FaRedditAlien,
},
{
name: "Email",
url: `mailto:?subject=${encodeURIComponent(
shareTitle
)}&body=${encodeURIComponent(shareUrl)}`,
icon: FaEnvelope,
},
];
const handleShare = (option) => {
if (option.name === "Copy Link") {
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
onClose();
}, 1000);
});
} else {
window.open(option.url, "_blank", "width=600,height=400");
onClose();
}
};
const handleCopyLink = () => {
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => {
setCopied(false);
onClose();
}, 1000);
});
};
if (!shouldRender) return null;
return (
<div
ref={popupRef}
className={`tb-share-popup${
isLargeMobile ? " tb-mobile-share-popup" : ""
} ${
isExiting ? "tb-menu-popup-animated-exit" : "tb-menu-popup-animated"
}`}
style={{
top: `${position.top}px`,
...(position.right ? { right: `${position.right}px` } : {}),
...(position.left ? { left: `${position.left}px` } : {}),
}}
>
<div
className={`tb-share-popup-container${
isLargeMobile ? " tb-mobile-share-popup-container" : ""
}`}
>
<div className="tb-share-list">
{shareOptions.map((option, index) => (
<button
onClick={() => handleShare(option)}
className="tb-share-item"
key={index}
>
<span className="tb-share-icon" aria-hidden="true">
<option.icon />
</span>
<span className="tb-share-label">{option.name}</span>
</button>
))}
<button onClick={handleCopyLink} className="tb-share-item">
<span className="tb-share-icon" aria-hidden="true">
<FaLink />
</span>
<span className="tb-share-label">
{copied ? "Copied!" : "Copy Link"}
</span>
</button>
</div>
</div>
</div>
);
};
SharePopupMenu.propTypes = {
isVisible: PropTypes.bool,
onClose: PropTypes.func,
buttonRef: PropTypes.shape({
current: PropTypes.instanceOf(HTMLElement),
}),
url: PropTypes.string,
title: PropTypes.string,
};
export default SharePopupMenu;

View File

@ -0,0 +1,142 @@
import { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useMediaQuery } from "react-responsive";
import Website2024 from "../../svgs/Website2024";
import Website2025 from "../../svgs/Website2025";
import Website2026 from "../../svgs/Website2026";
const WebsiteSelectorPopupMenu = ({
isVisible = false,
onClose = () => {},
buttonRef = null,
websiteOptions = [
{ svg: <Website2026 />, year: "2026", url: "https://tombutcher.work" },
{ svg: <Website2025 />, year: "2025", url: "https://2025.tombutcher.work" },
{ svg: <Website2024 />, year: "2024", url: "https://2024.tombutcher.work" },
],
}) => {
const [shouldRender, setShouldRender] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [position, setPosition] = useState({ top: 0 });
const popupRef = useRef(null);
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
useEffect(() => {
if (isVisible) {
setShouldRender(true);
setIsExiting(false);
// Calculate position based on button
const updatePosition = () => {
if (buttonRef?.current) {
const rect = buttonRef.current.getBoundingClientRect();
if (window.innerWidth < 450) {
console.log(rect.left);
setPosition({
top: rect.bottom + 10,
left: rect.left,
});
} else {
setPosition({
top: rect.bottom + 10,
right: window.innerWidth - rect.right,
});
}
}
};
// Update position immediately and on resize
updatePosition();
window.addEventListener("resize", updatePosition);
return () => window.removeEventListener("resize", updatePosition);
} else if (shouldRender) {
setIsExiting(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
}, 800); // Match animation duration
return () => clearTimeout(timer);
}
}, [isVisible, shouldRender, buttonRef]);
// Handle click outside
useEffect(() => {
if (!shouldRender || isExiting) return;
const handleClickOutside = (event) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target) &&
buttonRef?.current &&
!buttonRef.current.contains(event.target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [shouldRender, isExiting, onClose, buttonRef]);
const handleWebsiteSelect = (option) => {
// Navigate to the selected website
if (option.url) {
window.location.href = option.url;
}
onClose();
};
if (!shouldRender) return null;
return (
<div
ref={popupRef}
className={`tb-website-selector-popup${
isLargeMobile ? " tb-mobile-website-selector-popup" : ""
} ${
isExiting ? "tb-menu-popup-animated-exit" : "tb-menu-popup-animated"
}`}
style={{
top: `${position.top}px`,
...(position.right ? { right: `${position.right}px` } : {}),
...(position.left ? { left: `${position.left}px` } : {}),
}}
>
<div
className={`tb-website-selector-popup-container${
isLargeMobile ? " tb-mobile-website-selector-popup-container" : ""
}`}
>
<div className="tb-website-selector-list">
{websiteOptions.map((option, index) => (
<button
onClick={() => handleWebsiteSelect(option)}
className="tb-website-selector-item"
key={index}
>
{option.svg}
</button>
))}
</div>
</div>
</div>
);
};
WebsiteSelectorPopupMenu.propTypes = {
isVisible: PropTypes.bool,
onClose: PropTypes.func,
buttonRef: PropTypes.shape({
current: PropTypes.instanceOf(HTMLElement),
}),
websiteOptions: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
year: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})
),
};
export default WebsiteSelectorPopupMenu;

110
src/components/Page.jsx Normal file
View File

@ -0,0 +1,110 @@
import { useRef, memo, useMemo, useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Layout } from "antd";
import ContentRenderer from "./ContentRenderer";
import ScrollIcon from "../icons/ScrollIcon";
import { useMediaQuery } from "react-responsive";
import { useSettingsContext } from "../contexts/SettingsContext";
import ParticlesBackground from "./ParticlesBackground";
const { Content } = Layout;
const Page = memo(({ pageData, particlesVisible }) => {
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
const contentRef = useRef(null);
const settings = useSettingsContext();
const [delayedParticlesVisible, setDelayedParticlesVisible] = useState(false);
const theme = useMemo(
() => settings.themes.find((theme) => theme.name === pageData.theme),
[settings.themes, pageData.theme]
);
useEffect(() => {
if (particlesVisible) {
const timer = setTimeout(() => {
setDelayedParticlesVisible(true);
}, 100);
return () => clearTimeout(timer);
} else {
setDelayedParticlesVisible(false);
}
}, [particlesVisible]);
return (
<div
className={
!isMobile
? "tb-page-container"
: "tb-page-container tb-page-container-mobile"
}
style={{
"--tb-backgroundColor": theme?.backgroundColor,
"--tb-textColor": theme?.textColor,
background: "var(--tb-backgroundColor)",
}}
>
{pageData?.gradientBackground == true && particlesVisible == true && (
<ParticlesBackground theme={theme} visible={delayedParticlesVisible} />
)}
<Content
className={`tb-page-content ${
isLargeMobile ? " tb-page-content-mobile" : ""
}`}
ref={contentRef}
>
<div className="tb-content-container-wrapper">
<div
className={`tb-content-container tb-content-align-${
pageData?.align
} tb-content-justify-${pageData?.justify} ${
isFullHeight ? "tb-content-container-full-height" : ""
} ${
pageData?.verticalScroll ? "tb-content-container-vscroll" : ""
} ${
pageData?.horizontalScroll ? "tb-content-container-hscroll" : ""
}`}
style={{
gap: `${pageData?.spacing}px`,
}}
>
<ContentRenderer
content={pageData?.content}
paragraphWidth={pageData?.paragraphWidth}
verticalScroll={pageData?.verticalScroll}
horizontalScroll={pageData?.horizontalScroll}
gap={pageData?.spacing}
scrollSnap={pageData?.scrollSnap}
align={pageData?.align}
justify={pageData?.justify}
/>
{pageData?.showScroll == true && <ScrollIcon />}
</div>
</div>
</Content>
</div>
);
});
Page.displayName = "Page";
Page.propTypes = {
id: PropTypes.number,
pageData: PropTypes.object,
theme: PropTypes.shape({
backgroundColor: PropTypes.any,
textColor: PropTypes.any,
}),
isSubPage: PropTypes.bool,
showClose: PropTypes.bool,
showMenu: PropTypes.bool,
themes: PropTypes.any,
onClose: PropTypes.func,
particlesVisible: PropTypes.bool,
};
export default Page;

View File

@ -0,0 +1,177 @@
import PropTypes from "prop-types";
import React, {
useState,
useEffect,
useMemo,
useCallback,
useRef,
} from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim";
import convert from "color-convert";
const ParticlesComponent = React.memo(({ id, options, particlesLoaded }) => {
return (
<>
<Particles id={id} options={options} particlesLoaded={particlesLoaded} />
<div className="tb-particles-background" />
</>
);
});
ParticlesComponent.propTypes = {
id: PropTypes.string.isRequired,
options: PropTypes.any,
particlesLoaded: PropTypes.any,
};
ParticlesComponent.displayName = "ParticlesComponent";
const ParticlesBackground = ({ id, theme, visible }) => {
// Memoize colors to prevent glitching from unnecessary recalculations
const colors = useMemo(() => {
// Generate three transitional hues between backgroundColor and textColor
if (!theme?.backgroundColor || !theme?.textColor) {
return [];
}
try {
// Convert hex colors to RGB arrays using color-convert
const bgColor = convert.hex.rgb(theme.backgroundColor.replace("#", ""));
const textColor = convert.hex.rgb(theme.textColor.replace("#", ""));
// Generate 3 intermediate colors
const generatedColors = [];
for (let i = 0; i <= 2; i++) {
const ratio = i / 12; // 0, 0.5, 1
const r = Math.round(bgColor[0] + (textColor[0] - bgColor[0]) * ratio);
const g = Math.round(bgColor[1] + (textColor[1] - bgColor[1]) * ratio);
const b = Math.round(bgColor[2] + (textColor[2] - bgColor[2]) * ratio);
// Convert RGB back to hex using color-convert
const hexColor = "#" + convert.rgb.hex(r, g, b);
generatedColors.push(hexColor);
}
return generatedColors;
} catch (error) {
console.error("Error generating colors:", error);
return ["#FF00A1", "#0310FF", "#2DE2FF"];
}
}, [theme?.backgroundColor, theme?.textColor]);
const [init, setInit] = useState(false);
const instanceId = useRef(
id || `tsparticles-${Math.random().toString(36).substr(2, 9)}`
);
// this should be run only once per application lifetime
useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine);
}).then(() => {
setInit(true);
});
}, []);
const particlesLoaded = useCallback((container) => {
console.log(container);
}, []);
const options = useMemo(
() => ({
background: {
color: {
value: theme?.backgroundColor,
},
},
fpsLimit: 120,
interactivity: {
events: {
onClick: {
enable: false,
mode: "push",
},
onHover: {
enable: true,
mode: "repulse",
},
},
modes: {
push: {
quantity: 4,
},
repulse: {
distance: 100,
duration: 5,
},
},
},
particles: {
color: {
value: colors,
},
links: {
color: "#ffffff",
distance: 150,
enable: true,
opacity: 0.0,
width: 0,
},
move: {
direction: "none",
enable: true,
outModes: {
default: "out",
},
random: true,
speed: 1,
straight: false,
},
number: {
density: {
enable: true,
},
value: 400,
},
opacity: {
value: 1,
},
shape: {
type: "circle",
},
size: {
value: { min: 100, max: 300 },
},
},
detectRetina: true,
}),
[colors]
);
return (
<>
{init && theme?.backgroundColor && theme?.textColor && (
<div
className={
visible
? "tb-particles tb-particles-visible"
: "tb-particles tb-particles-hidden"
}
>
<ParticlesComponent
id={instanceId.current}
options={options}
particlesLoaded={particlesLoaded}
/>
</div>
)}
</>
);
};
ParticlesBackground.propTypes = {
id: PropTypes.string,
theme: PropTypes.object,
visible: PropTypes.bool,
};
export default React.memo(ParticlesBackground);

View File

@ -0,0 +1,39 @@
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import Image from "../Image";
import ProjectStatus from "./ProjectStatus";
const ProjectCard = ({ projectData, key }) => {
const navigate = useNavigate();
return (
<div
className="tb-project-card-wrapper"
onClick={() => {
navigate(`/projects/${projectData.slug}`);
}}
>
<div className="tb-project-card" key={key}>
<div className="tb-project-card-image-wrapper">
{projectData?.image ? (
<Image src={projectData?.image} alt={projectData?.name} />
) : (
<div className="tb-project-card-placeholder-image">No Image</div>
)}
</div>
<div className="tb-project-card-content">
<h3 className="tb-project-card-title">{projectData?.name}</h3>
{projectData?.status && projectData?.status !== "complete" && (
<ProjectStatus status={projectData?.status} />
)}
</div>
</div>
</div>
);
};
ProjectCard.propTypes = {
key: PropTypes.any,
projectData: PropTypes.object,
};
export default ProjectCard;

View File

@ -0,0 +1,21 @@
import ProjectCard from "./ProjectCard";
import { useProjects } from "../../contexts/ProjectsContext";
const ProjectCards = () => {
const { projects } = useProjects();
return (
<div className="tb-project-cards-container">
<div className="tb-project-cards" id="project-cards-container">
{projects.map((project, i) => (
<ProjectCard projectData={project} key={i} />
))}
</div>
</div>
);
};
ProjectCards.propTypes = {};
export default ProjectCards;

View File

@ -0,0 +1,134 @@
import { useRef, memo, useMemo } from "react";
import PropTypes from "prop-types";
import { Layout } from "antd";
import ContentRenderer from "../ContentRenderer";
import { useMediaQuery } from "react-responsive";
import ShareButton from "../Buttons/ShareButton";
import VisitButton from "../Buttons/VisitButton";
import ProjectStatus from "./ProjectStatus";
import { useSettingsContext } from "../../contexts/SettingsContext";
const { Content } = Layout;
const ProjectPage = memo(({ projectData }) => {
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const isFullHeight = !useMediaQuery({ maxHeight: 550 });
const contentRef = useRef(null);
const settings = useSettingsContext();
const theme = useMemo(
() =>
projectData?.theme != undefined
? settings.themes.find((theme) => theme.name === projectData.theme)
: settings.globalThemes.project,
[settings.themes, settings.globalThemes.project, projectData]
);
if (projectData == null) {
return null;
}
const projectHeader = (
<div className="tb-project-header">
<div className="tb-project-header-title">
<h1
className={`tb-project-title ${
isMobile ? "tb-project-title-mobile" : ""
}`}
>
{projectData?.name || "n/a"}
</h1>
<div className="tb-project-header-buttons">
{projectData?.externalLink && (
<VisitButton url={projectData?.externalLink} />
)}
<ShareButton projectData={projectData} theme={theme} />
</div>
</div>
<div className="tb-project-header-meta">
<div className="tb-project-header-meta-items tb-project-header-meta-items-left">
<p className="tb-project-subtitle">
{projectData?.subTitle || "n/a"}
</p>
<div className="tb-project-info-wrapper">
<p className="tb-project-client">
<span className="tb-project-client-label">Client:</span>
{projectData?.client || "n/a"}
</p>
<div className="tb-project-tools-list">
{projectData?.tools?.map((tool) => (
<p className="tb-project-tool-badge" key={tool}>
{tool}
</p>
))}
</div>
</div>
</div>
<div className="tb-project-header-meta-items tb-project-header-meta-items-right">
<p className="tb-project-date">{projectData?.date || "n/a"}</p>
<div className="tb-project-type-badge-wrapper">
{projectData?.status && projectData?.status !== "complete" && (
<ProjectStatus status={projectData?.status} />
)}
<p className="tb-project-type-badge">
{projectData?.type || "n/a"}
</p>
</div>
</div>
</div>
</div>
);
return (
<div
className={
!isMobile
? "tb-page-container tb-project-page"
: "tb-page-container tb-page-container-mobile tb-project-page"
}
style={{
"--tb-textColor": theme?.textColor,
"--tb-backgroundColor": theme?.backgroundColor,
background: theme?.backgroundColor,
color: theme?.textColor,
}}
>
<Content
className={`tb-page-content tb-project-content ${
isLargeMobile
? " tb-page-content-mobile tb-project-content-mobile"
: ""
}`}
ref={contentRef}
>
<div className="tb-content-container-wrapper">
<div
className={`tb-content-container tb-content-align-left tb-content-justify-top ${
isFullHeight ? "tb-content-container-full-height" : ""
} tb-content-container-vscroll`}
style={{
gap: "20px",
}}
>
<ContentRenderer
content={projectData?.content}
verticalScroll={true}
gap={12}
firstElement={projectHeader}
/>
</div>
</div>
</Content>
</div>
);
});
ProjectPage.displayName = "ProjectPage";
ProjectPage.propTypes = {
projectData: PropTypes.object.isRequired,
};
export default ProjectPage;

View File

@ -0,0 +1,22 @@
import PropTypes from "prop-types";
const ProjectStatus = ({ status }) => {
const statusName =
status === "planned"
? "Planned"
: status === "inProgress"
? "In Progress"
: "Complete";
return (
<p className={`tb-project-status-badge tb-project-status-badge-${status}`}>
{statusName}
</p>
);
};
ProjectStatus.propTypes = {
status: PropTypes.string,
};
export default ProjectStatus;

View File

@ -0,0 +1,97 @@
import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react";
import { useMediaQuery } from "react-responsive";
export default function SubPage({
visible = false,
children,
mousePosition,
skipAnimation = false,
}) {
const isMobile = useMediaQuery({ maxWidth: 800 });
const [isExiting, setIsExiting] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
const [shouldAnimate, setShouldAnimate] = useState(false);
const mousePositionRef = useRef(null);
const capturedMousePositionRef = useRef(null);
useEffect(() => {
mousePositionRef.current = mousePosition;
}, [mousePosition]);
useEffect(() => {
if (visible === true) {
setIsExiting(false);
setShouldAnimate(false);
capturedMousePositionRef.current = mousePositionRef.current;
// Ensure DOM is ready before showing - use double requestAnimationFrame for Edge compatibility
// First frame: render element with initial state (no enter class yet)
requestAnimationFrame(() => {
setShouldRender(true);
// Second frame: ensure browser has painted initial state before adding enter class
requestAnimationFrame(() => {
setShouldAnimate(true);
});
});
// Capture mouse position when becoming visible
} else if (visible === false) {
setIsExiting(true);
capturedMousePositionRef.current = mousePositionRef.current;
setShouldRender(false);
setShouldAnimate(false);
// Capture mouse position when becoming hidden (for exit animation)
}
}, [visible]);
const wrapperClass = isMobile
? "tb-mobile-sub-page-wrapper"
: "tb-sub-page-wrapper";
const animationClass = skipAnimation
? ""
: isExiting
? `${wrapperClass}-exit`
: shouldAnimate
? `${wrapperClass}-enter`
: "";
if (!visible && !shouldRender) {
return null;
}
const skipAnimationClass = skipAnimation ? "tb-skip-animation" : "";
return (
<div
className={`${wrapperClass} ${animationClass} ${skipAnimationClass}`}
style={{
transformOrigin: capturedMousePositionRef.current
? `${capturedMousePositionRef.current.x}px ${capturedMousePositionRef.current.y}px`
: "center center",
}}
>
{/* Full height panel */}
<div
className={`tb-sub-page-inner ${skipAnimationClass} ${
isExiting
? "tb-sub-page-inner-exit"
: shouldAnimate
? "tb-sub-page-inner-enter"
: ""
}`}
>
{children}
</div>
</div>
);
}
SubPage.propTypes = {
children: PropTypes.any,
visible: PropTypes.bool,
mousePosition: PropTypes.shape({
x: PropTypes.number,
y: PropTypes.number,
}),
skipAnimation: PropTypes.bool,
};

570
src/components/Video.jsx Normal file
View File

@ -0,0 +1,570 @@
import PropTypes from "prop-types";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import { useVideoContext } from "../contexts/VideoContext";
import LoadingIcon from "../icons/LoadingIcon";
import FullScreenIcon from "../icons/FullScreenIcon";
import PlayIcon from "../icons/PlayIcon";
import PauseIcon from "../icons/PauseIcon";
import ExitFullScreenIcon from "../icons/ExitFullScreenIcon";
import Slider from "rc-slider";
import "rc-slider/assets/index.css";
import VideoVolume from "./VideoVolume";
import Volume1Icon from "../icons/Volume1Icon";
import Volume2Icon from "../icons/Volume2Icon";
import Volume3Icon from "../icons/Volume3Icon";
import MuteIcon from "../icons/MuteIcon";
const Video = ({ src, mirrorUrl = null, className, poster, ...props }) => {
const { videoStates, loadVideo, getVideoUrl } = useVideoContext();
const containerRef = useRef(null);
const videoRef = useRef(null);
const processedSrcRef = useRef(null);
const muteButtonRef = useRef(null);
const getFullscreenElement = useCallback(() => {
if (typeof document === "undefined") return null;
// Cross-browser fullscreen element getter
return (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement ||
null
);
}, []);
const [videoLoaded, setVideoLoaded] = useState(false);
const [renderError, setRenderError] = useState(false);
const [showError, setShowError] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(true);
const [volume, setVolume] = useState(0);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [controlsVisible, setControlsVisible] = useState(false);
const hideControlsTimeoutRef = useRef(null);
const [isHoveringMute, setIsHoveringMute] = useState(false);
const [isHoveringVolumePopup, setIsHoveringVolumePopup] = useState(false);
const [hideVolumeTimeout, setHideVolumeTimeout] = useState(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const currentVideoState = src ? videoStates[src] : null;
const objectUrl = useMemo(
() => (src ? getVideoUrl(src) : null),
[getVideoUrl, src]
);
useEffect(() => {
// If volume is set to zero, ensure muted state is true.
// Increasing volume will unmute via handleVolumeSlider.
if (volume === 0) {
const el = videoRef.current;
if (el) el.muted = true;
setIsMuted(true);
}
}, [volume]);
// Reset markers when src changes
useEffect(() => {
if (processedSrcRef.current !== src) {
processedSrcRef.current = src;
setVideoLoaded(false);
setRenderError(false);
setShowError(false);
setIsPlaying(false);
setIsMuted(true);
setCurrentTime(0);
setDuration(0);
}
}, [src]);
// Load video when unloaded
useEffect(() => {
if (!src) return;
if (!currentVideoState || currentVideoState.loadingState === "unloaded") {
void loadVideo(src, mirrorUrl);
}
}, [src, currentVideoState, loadVideo, mirrorUrl]);
// Wire basic video element handlers when source becomes available
useEffect(() => {
const el = videoRef.current;
if (!el) return;
// iOS Safari native fullscreen events
const handleBeginFullscreen = () => setIsFullscreen(true);
const handleEndFullscreen = () => setIsFullscreen(false);
const handleLoadedMetadata = () => {
setDuration(el.duration || 0);
};
const handleCanPlay = () => {
setVideoLoaded(true);
};
const handleTimeUpdate = () => {
setCurrentTime(el.currentTime || 0);
};
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleError = () => {
setVideoLoaded(false);
setRenderError(true);
};
el.addEventListener("loadedmetadata", handleLoadedMetadata);
el.addEventListener("canplay", handleCanPlay);
el.addEventListener("timeupdate", handleTimeUpdate);
el.addEventListener("play", handlePlay);
el.addEventListener("pause", handlePause);
el.addEventListener("error", handleError);
el.addEventListener("webkitbeginfullscreen", handleBeginFullscreen);
el.addEventListener("webkitendfullscreen", handleEndFullscreen);
return () => {
el.removeEventListener("loadedmetadata", handleLoadedMetadata);
el.removeEventListener("canplay", handleCanPlay);
el.removeEventListener("timeupdate", handleTimeUpdate);
el.removeEventListener("play", handlePlay);
el.removeEventListener("pause", handlePause);
el.removeEventListener("error", handleError);
el.removeEventListener("webkitbeginfullscreen", handleBeginFullscreen);
el.removeEventListener("webkitendfullscreen", handleEndFullscreen);
};
}, [objectUrl]);
// Ensure muted autoplay when a source becomes available
useEffect(() => {
const el = videoRef.current;
if (!el || !objectUrl) return;
el.muted = true;
const maybePromise = el.play();
if (maybePromise && typeof maybePromise.then === "function") {
maybePromise.catch(() => {
// Autoplay can be blocked in edge cases; ignore errors silently.
});
}
}, [objectUrl]);
// Listen for fullscreen change events (cross-browser) and track state
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(Boolean(getFullscreenElement()));
};
// Initial sync
handleFullscreenChange();
document.addEventListener("fullscreenchange", handleFullscreenChange);
document.addEventListener("webkitfullscreenchange", handleFullscreenChange);
document.addEventListener("mozfullscreenchange", handleFullscreenChange);
document.addEventListener("MSFullscreenChange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
document.removeEventListener(
"webkitfullscreenchange",
handleFullscreenChange
);
document.removeEventListener(
"mozfullscreenchange",
handleFullscreenChange
);
document.removeEventListener(
"MSFullscreenChange",
handleFullscreenChange
);
};
}, [getFullscreenElement]);
// Show controls on mouse movement, then auto-hide after a delay
const showControlsTemporarily = useCallback(() => {
setControlsVisible(true);
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
hideControlsTimeoutRef.current = setTimeout(() => {
setIsHoveringMute(false);
setIsHoveringVolumePopup(false);
setControlsVisible(false);
}, 2500);
}, []);
useEffect(() => {
return () => {
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
};
}, []);
const handleRetry = useCallback(() => {
setVideoLoaded(false);
setRenderError(false);
setShowError(false);
if (src) {
void loadVideo(src, mirrorUrl);
}
}, [loadVideo, mirrorUrl, src]);
const togglePlay = useCallback(() => {
const el = videoRef.current;
if (!el) return;
if (el.paused) {
void el.play();
} else {
el.pause();
}
}, []);
const handleSeekSlider = useCallback(
(value) => {
const el = videoRef.current;
if (!el || !duration) return;
const next = Number(value);
el.currentTime = Math.min(Math.max(next, 0), duration);
setCurrentTime(el.currentTime);
},
[duration]
);
const handleVolumeSlider = useCallback(
(value) => {
const el = videoRef.current;
if (!el) return;
const v = Number(value);
el.volume = v;
setVolume(v);
if (v > 0 && isMuted) {
el.muted = false;
setIsMuted(false);
}
if (v == 0) {
setIsMuted(true);
} else {
setIsMuted(false);
}
},
[isMuted]
);
const toggleMute = useCallback(() => {
const el = videoRef.current;
if (!el) return;
const next = !isMuted;
el.muted = next;
setIsMuted(next);
if (next == true) {
setVolume(0);
} else if (next == false && volume <= 0.01) {
setVolume(1);
el.volume = 1;
} else {
setVolume(volume);
}
}, [isMuted, volume]);
const toggleFullscreen = useCallback(() => {
const container = containerRef.current;
const video = videoRef.current;
if (!container) return;
const fsEl =
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement;
if (fsEl) {
if (document.exitFullscreen) {
void document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
void document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
void document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
void document.msExitFullscreen();
}
return;
}
// Prefer container fullscreen where supported
if (container.requestFullscreen) {
void container.requestFullscreen();
} else if (container.webkitRequestFullscreen) {
void container.webkitRequestFullscreen();
} else if (container.mozRequestFullScreen) {
void container.mozRequestFullScreen();
} else if (container.msRequestFullscreen) {
void container.msRequestFullscreen();
} else if (video && typeof video.webkitEnterFullscreen === "function") {
// iOS Safari: fall back to native video fullscreen
try {
video.webkitEnterFullscreen();
} catch {
// no-op: some iOS versions require user gesture; button click qualifies
}
}
}, []);
const clearHideVolumeTimeout = useCallback(() => {
if (hideVolumeTimeout) {
clearTimeout(hideVolumeTimeout);
setHideVolumeTimeout(null);
}
}, [hideVolumeTimeout]);
const scheduleHideVolume = useCallback(
(delayMs = 150) => {
clearHideVolumeTimeout();
const t = setTimeout(() => {
setIsHoveringMute(false);
//setIsHoveringVolumePopup(false);
}, delayMs);
setHideVolumeTimeout(t);
},
[clearHideVolumeTimeout]
);
useEffect(() => {
return () => {
if (hideVolumeTimeout) {
clearTimeout(hideVolumeTimeout);
}
};
}, [hideVolumeTimeout]);
// Error UI (mirrors Image.jsx approach)
useEffect(() => {
const isError = currentVideoState?.loadingState === "error" || renderError;
if (isError) {
setRenderError(true);
requestAnimationFrame(() => setShowError(true));
} else if (renderError) {
setShowError(false);
}
}, [currentVideoState?.loadingState, renderError]);
useEffect(() => {
if (!showError && renderError) {
const timeout = setTimeout(() => setRenderError(false), 300);
return () => clearTimeout(timeout);
}
return undefined;
}, [showError, renderError]);
const errorContent = (
<div className="tb-image-error-content">
<h3 className="tb-image-error-title">Failed to load video</h3>
<button className="tb-button tb-image-error-button" onClick={handleRetry}>
Try again
</button>
</div>
);
// Render
return (
<div
ref={containerRef}
className={`tb-video-container ${className || ""} ${
videoLoaded !== true ? "tb-video-container-loading" : ""
}`}
onMouseMove={showControlsTemporarily}
>
{/* Loading overlay (reuse styles from Image.jsx) */}
{videoLoaded == false &&
(!currentVideoState || currentVideoState.loadingState !== "error") && (
<div className="tb-video-loading-container">
<div
className={`tb-video-loading ${videoLoaded ? "tb-hidden" : ""}`}
>
{typeof currentVideoState?.progress === "number" &&
isFinite(currentVideoState.progress) ? (
<div
className="tb-video-loading-progress"
aria-label={`Loading ${Math.round(
currentVideoState.progress * 100
)}%`}
>
<div
className="tb-video-loading-progress-bar"
style={{
width: `${Math.round(currentVideoState.progress * 100)}%`,
}}
/>
</div>
) : (
<LoadingIcon />
)}
</div>
</div>
)}
{/* Error overlay */}
{renderError ? (
<div
className={`tb-video-error ${showError ? "" : "tb-hidden"}`}
aria-hidden={!showError}
>
{errorContent}
</div>
) : null}
{/* Placeholder panel using tb-textColor @ 50% */}
{!objectUrl && <div className="tb-video-placeholder" />}
{/* Video element once loaded */}
{objectUrl && (
<div
className={`tb-video-container-inner${
isFullscreen ? " tb-video-container-inner-fullscreen" : ""
}`}
>
<video
ref={videoRef}
src={objectUrl}
poster={poster || undefined}
muted={isMuted}
autoPlay
loop
controls={false}
playsInline
onLoadedMetadata={() => {
const el = videoRef.current;
if (el) {
setDuration(el.duration || 0);
el.volume = 0;
setVolume(el.volume);
setIsMuted(el.muted);
}
}}
className="tb-video-content"
style={{ opacity: videoLoaded ? 1 : 0, ...props.style }}
{...props}
/>
</div>
)}
{/* Custom controls, themed like MenuContext buttons */}
{videoLoaded == true && (
<>
<div
className={`tb-video-controls ${
controlsVisible ? "" : "tb-hidden"
}${isFullscreen ? " tb-video-controls-fullscreen" : ""}`}
>
<button
className={`tb-button tb-menu-popup-button`}
onClick={togglePlay}
disabled={!objectUrl || renderError}
type="button"
>
<div className={`tb-icon-button${isPlaying ? " tb-hidden" : ""}`}>
<PlayIcon />
</div>
<div
className={`tb-icon-button${!isPlaying ? " tb-hidden" : ""}`}
>
<PauseIcon />
</div>
</button>
<div
style={{ flex: 1, display: "flex", alignItems: "center", gap: 8 }}
>
<Slider
min={0}
max={Math.max(0, Math.floor(duration))}
value={Math.min(Math.floor(currentTime), Math.floor(duration))}
onChange={handleSeekSlider}
disabled={!objectUrl || !isFinite(duration)}
className="tb-video-seek"
/>
</div>
<button
ref={muteButtonRef}
className={`tb-button tb-menu-popup-button tb-video-volume-button${
isHoveringMute || isHoveringVolumePopup
? " tb-menu-popup-button-active"
: ""
}`}
onClick={toggleMute}
disabled={!objectUrl}
type="button"
onMouseEnter={() => {
clearHideVolumeTimeout();
setIsHoveringMute(true);
}}
onMouseLeave={() => scheduleHideVolume(100)}
>
<div
className={`tb-icon-button${
!isMuted && volume >= 0.66 ? "" : " tb-hidden"
}`}
>
{" "}
<Volume3Icon />{" "}
</div>
<div
className={`tb-icon-button${
!isMuted && volume < 0.66 && volume > 0.33 ? "" : " tb-hidden"
}`}
>
{" "}
<Volume2Icon />{" "}
</div>
<div
className={`tb-icon-button${
!isMuted && volume <= 0.33 ? "" : " tb-hidden"
}`}
>
{" "}
<Volume1Icon />{" "}
</div>
<div className={`tb-icon-button${isMuted ? "" : " tb-hidden"}`}>
{" "}
<MuteIcon />{" "}
</div>
</button>
<div className="tb-video-controls-separator"></div>
<button
className="tb-button tb-menu-popup-button"
onClick={toggleFullscreen}
type="button"
>
<div
className={`tb-icon-button${isFullscreen ? " tb-hidden" : ""}`}
>
<FullScreenIcon />
</div>
<div
className={`tb-icon-button${!isFullscreen ? " tb-hidden" : ""}`}
>
<ExitFullScreenIcon />
</div>
</button>
</div>
<VideoVolume
isVisible={
(isHoveringMute || isHoveringVolumePopup) &&
Boolean(muteButtonRef.current)
}
onClose={() => {
setIsHoveringMute(false);
setIsHoveringVolumePopup(false);
}}
buttonRef={muteButtonRef}
volume={volume}
onChange={handleVolumeSlider}
disabled={!objectUrl}
onMouseEnter={() => {
clearHideVolumeTimeout();
setIsHoveringVolumePopup(true);
}}
onMouseLeave={() => scheduleHideVolume()}
/>
</>
)}
</div>
);
};
Video.propTypes = {
className: PropTypes.string,
mirrorUrl: PropTypes.string,
poster: PropTypes.string,
src: PropTypes.string,
style: PropTypes.object,
};
export default Video;

View File

@ -0,0 +1,149 @@
import PropTypes from "prop-types";
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import Slider from "rc-slider";
import "rc-slider/assets/index.css";
const VideoVolume = ({
isVisible = false,
onClose = () => {},
buttonRef = null,
volume = 0,
onChange = () => {},
disabled = false,
onMouseEnter: onMouseEnterProp,
onMouseLeave: onMouseLeaveProp,
}) => {
const [shouldRender, setShouldRender] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [position, setPosition] = useState(null);
const [isPositioned, setIsPositioned] = useState(false);
const popupRef = useRef(null);
const updatePosition = useCallback(() => {
if (!buttonRef?.current || !popupRef.current) return;
const buttonRect = buttonRef.current.getBoundingClientRect();
const margin = 8;
// Absolute positioning: compute relative to offsetParent bounds
const offsetParent = popupRef.current.offsetParent || document.body;
const parentRect = offsetParent.getBoundingClientRect();
const bottomOffset = Math.max(
0,
parentRect.bottom - buttonRect.top + margin
);
const rightOffset = Math.max(0, parentRect.right - buttonRect.right);
setPosition({ bottom: bottomOffset, right: rightOffset + 5 });
setIsPositioned(true);
}, [buttonRef]);
useEffect(() => {
if (isVisible) {
setIsPositioned(false);
setShouldRender(true);
setIsExiting(false);
// position after render
const raf1 = requestAnimationFrame(() => {
const raf2 = requestAnimationFrame(() => {
updatePosition();
});
return () => cancelAnimationFrame(raf2);
});
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
cancelAnimationFrame(raf1);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
} else if (shouldRender) {
setIsExiting(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
setPosition(null);
setIsPositioned(false);
}, 800);
return () => clearTimeout(timer);
}
return undefined;
}, [isVisible, shouldRender, updatePosition]);
// Optional outside click close (does not fight hover logic if parent keeps visible while hovered)
useEffect(() => {
if (!shouldRender || isExiting) return undefined;
const handleClickOutside = (event) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target) &&
buttonRef?.current &&
!buttonRef.current.contains(event.target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [shouldRender, isExiting, onClose, buttonRef]);
const sliderValue = useMemo(() => {
const v = Number(volume);
if (!isFinite(v)) return 0;
return Math.min(1, Math.max(0, v));
}, [volume]);
if (!shouldRender) return null;
const positioningStyle =
position != null
? {
bottom: `${position.bottom}px`,
right: `${position.right}px`,
}
: {};
return (
<div
ref={popupRef}
className={`tb-video-volume-popup ${
isExiting ? "tb-menu-popup-animated-exit" : "tb-menu-popup-animated"
}`}
style={{
...positioningStyle,
visibility: isPositioned ? "visible" : "hidden",
pointerEvents: isPositioned ? undefined : "none",
}}
onMouseEnter={onMouseEnterProp}
onMouseLeave={onMouseLeaveProp}
>
<div className="tb-video-volume-popup-container">
<Slider
vertical
min={0}
max={1}
step={0.01}
value={sliderValue}
onChange={onChange}
disabled={disabled}
className="tb-video-volume tb-video-volume-vertical"
// Provide a bit of padding so the handle is easy to grab at ends
/>
</div>
</div>
);
};
VideoVolume.propTypes = {
isVisible: PropTypes.bool,
onClose: PropTypes.func,
buttonRef: PropTypes.shape({
current: PropTypes.instanceOf(HTMLElement),
}),
volume: PropTypes.number,
onChange: PropTypes.func,
disabled: PropTypes.bool,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
};
export default VideoVolume;

View File

@ -0,0 +1,233 @@
import { createContext, useContext, useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useMediaQuery } from "react-responsive";
import { useKeycloak } from "./KeycloakContext";
import LoggedOutIcon from "../icons/LoggedOutIcon";
import LoadingIcon from "../icons/LoadingIcon";
import UserIcon from "../icons/UserIcon";
import VisitIcon from "../icons/VisitIcon";
import CloseIcon from "../icons/CloseIcon";
import PadlockIcon from "../icons/PadlockIcon";
import AtSymbolIcon from "../icons/AtSymbolIcon";
// Create the context
const AccountContext = createContext();
// eslint-disable-next-line react-refresh/only-export-components
export const useAccount = () => useContext(AccountContext);
export const AccountProvider = ({ children }) => {
const {
keycloak,
isAuthenticated,
user,
loading: keycloakLoading,
} = useKeycloak();
const [accountVisible, setAccountVisible] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const accountMenuPopupRef = useRef(null);
const accountButtonRef = useRef(null);
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
// Handle login
const handleLogin = async () => {
try {
await keycloak.login();
} catch (error) {
console.error("Login failed", error);
}
};
// Handle logout
const handleLogout = async () => {
try {
await keycloak.logout();
setAccountVisible(false);
} catch (error) {
console.error("Logout failed", error);
}
};
// Handle manage account (Keycloak account console)
const handleManageAccount = async () => {
try {
if (keycloak?.accountManagement) {
await keycloak.accountManagement();
return;
}
} catch (error) {
console.warn("Keycloak accountManagement redirect failed", error);
}
try {
const authServerUrl = keycloak?.authServerUrl?.replace(/\/$/, "");
const realm = keycloak?.realm;
if (authServerUrl && realm) {
const accountUrl = `${authServerUrl}/realms/${realm}/account`;
window.open(accountUrl, "_blank", "noopener,noreferrer");
}
} catch (error) {
console.error("Opening account management failed", error);
}
};
// Handle account menu visibility with animation
useEffect(() => {
if (accountVisible) {
console.log("accountVisible", accountVisible);
setShouldRender(true);
setIsExiting(false);
} else if (shouldRender) {
setIsExiting(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
}, 800); // Match animation duration
return () => clearTimeout(timer);
}
}, [accountVisible, shouldRender]);
// Handle click outside account menu
useEffect(() => {
if (!shouldRender || isExiting) return;
const handleClickOutside = (event) => {
// Don't close if clicking on the account button - it handles its own toggle
const accountButton = event.target.closest(".tb-footer-button-account");
if (accountButton) {
return;
}
// Check if click is outside the account menu popup
if (
accountMenuPopupRef.current &&
!accountMenuPopupRef.current.contains(event.target)
) {
setAccountVisible(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [shouldRender, isExiting]);
return (
<AccountContext.Provider
value={{
accountVisible,
setAccountVisible,
isAuthenticated,
user,
loading: keycloakLoading,
handleLogin,
handleLogout,
accountButtonRef,
}}
>
{children}
{/* Account menu overlay */}
{shouldRender && (
<div
ref={accountMenuPopupRef}
className={`tb-account-menu-popup${
isLargeMobile ? " tb-mobile-account-menu-popup" : ""
} ${
isExiting
? "tb-account-menu-popup-animated-exit"
: "tb-account-menu-popup-animated"
}`}
>
<div
className={`tb-account-menu-popup-container${
isLargeMobile ? " tb-mobile-account-menu-popup-container" : ""
}`}
>
{keycloakLoading ? (
<>
<div className="tb-account-menu-header">
<div className="tb-account-menu-loading">
<LoadingIcon />
</div>
</div>
</>
) : !isAuthenticated ? (
<>
<div className="tb-account-menu-header">
<div className="tb-account-menu-icon">
<LoggedOutIcon />
</div>
<div className="tb-account-menu-user-info">
<div className="tb-account-menu-user-name">Logged out</div>
<div className="tb-account-menu-user-email">
Please log in to continue.
</div>
</div>
<button
className="tb-button tb-account-menu-button tb-account-menu-login-button"
onClick={handleLogin}
>
Login
<PadlockIcon />
</button>
</div>
</>
) : (
<>
<div className="tb-account-menu-header">
<div className="tb-account-menu-icon">
<UserIcon />
</div>
<div className="tb-account-menu-user-info">
{user?.firstName && user?.lastName && (
<div className="tb-account-menu-user-name">
{user.firstName} {user.lastName}
{user?.username && (
<div className="tb-account-menu-user-username">
<div className="tb-account-menu-user-username-at">
<AtSymbolIcon />
</div>
<div className="tb-account-menu-user-username-content">
{user.username}
</div>
</div>
)}
</div>
)}
{user?.email && (
<div className="tb-account-menu-user-email">
{user.email}
</div>
)}
</div>
</div>
<div className="tb-account-menu-footer">
<button
className="tb-button tb-account-menu-button"
onClick={handleManageAccount}
>
Manage Account
<VisitIcon />
</button>
<div className="tb-account-menu-divider"></div>
<button
className="tb-button tb-account-menu-button tb-account-menu-logout-button"
onClick={handleLogout}
>
Logout
<CloseIcon />
</button>
</div>
</>
)}
</div>
</div>
)}
</AccountContext.Provider>
);
};
AccountProvider.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@ -0,0 +1,29 @@
import React, { createContext, useContext, useEffect } from "react";
import { useLocation } from "react-router-dom";
const ActionContext = createContext(null);
export const ActionProvider = ({ onAction, children }) => {
const location = useLocation();
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const action = searchParams.get("action");
if (action && typeof onAction?.[action] === "function") {
// collect all params except action
const params = {};
searchParams.forEach((value, key) => {
if (key !== "action") {
params[key] = value;
}
});
onAction[action](params);
}
}, [location, onAction]);
return <ActionContext.Provider value={{}}>{children}</ActionContext.Provider>;
};
export const useActionContext = () => useContext(ActionContext);

View File

@ -0,0 +1,34 @@
import PropTypes from "prop-types";
import { createContext, useContext, useState, useEffect } from "react";
// Create context
const BlogsContext = createContext({
blogs: [],
setBlogs: () => {},
});
// Provider
export const BlogsProvider = ({ children, initialBlogs = [] }) => {
const [blogs, setBlogs] = useState(initialBlogs);
// Update blogs whenever initialBlogs changes
useEffect(() => {
if (initialBlogs && initialBlogs.length > 0) {
setBlogs(initialBlogs);
}
}, [initialBlogs]);
return (
<BlogsContext.Provider value={{ blogs, setBlogs }}>
{children}
</BlogsContext.Provider>
);
};
BlogsProvider.propTypes = {
children: PropTypes.any,
initialBlogs: PropTypes.array,
};
// Hook for consuming
export const useBlogs = () => useContext(BlogsContext);

View File

@ -0,0 +1,34 @@
import PropTypes from "prop-types";
import { createContext, useContext, useState, useEffect } from "react";
// Create context
const CompaniesContext = createContext({
companies: [],
setCompanies: () => {},
});
// Provider
export const CompaniesProvider = ({ children, initialCompanies = [] }) => {
const [companies, setCompanies] = useState(initialCompanies);
// Update companies whenever initialCompanies changes
useEffect(() => {
if (initialCompanies && initialCompanies.length > 0) {
setCompanies(initialCompanies);
}
}, [initialCompanies]);
return (
<CompaniesContext.Provider value={{ companies, setCompanies }}>
{children}
</CompaniesContext.Provider>
);
};
CompaniesProvider.propTypes = {
children: PropTypes.any,
initialCompanies: PropTypes.array,
};
// Hook for consuming
export const useCompanies = () => useContext(CompaniesContext);

View File

@ -0,0 +1,232 @@
import PropTypes from "prop-types";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
} from "react";
// Simple file cache that stores object URLs (from Blobs)
class FileCache {
constructor() {
this.cache = new Map(); // url -> objectUrl
this.loadingPromises = new Map(); // url -> Promise<string>
}
isCached(url) {
return this.cache.has(url);
}
get(url) {
return this.cache.get(url) || null;
}
set(url, objectUrl) {
// Revoke old object URL if overwriting
if (this.cache.has(url)) {
const oldUrl = this.cache.get(url);
if (oldUrl && oldUrl !== objectUrl) {
try {
URL.revokeObjectURL(oldUrl);
} catch {
// ignore
}
}
}
this.cache.set(url, objectUrl);
}
remove(url) {
if (this.cache.has(url)) {
const objectUrl = this.cache.get(url);
try {
URL.revokeObjectURL(objectUrl);
} catch {
// ignore
}
this.cache.delete(url);
}
this.loadingPromises.delete(url);
}
clear() {
for (const [, objectUrl] of this.cache.entries()) {
try {
URL.revokeObjectURL(objectUrl);
} catch {
// ignore
}
}
this.cache.clear();
this.loadingPromises.clear();
}
async _fetchToObjectUrl(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
}
async loadFile(primaryUrl, mirrorUrl = null) {
// Use cache if present
const cached = this.get(primaryUrl);
if (cached) {
return cached;
}
// Reuse in-flight request if present
if (this.loadingPromises.has(primaryUrl)) {
return this.loadingPromises.get(primaryUrl);
}
// Compose a single loading promise and store it to dedupe concurrent loads
const loadingPromise = (async () => {
// Try mirror first if provided
if (mirrorUrl) {
try {
const objUrl = await this._fetchToObjectUrl(mirrorUrl);
this.set(primaryUrl, objUrl); // cache under primary URL key
return objUrl;
} catch {
// fall through to primary
}
}
const objUrl = await this._fetchToObjectUrl(primaryUrl);
this.set(primaryUrl, objUrl);
return objUrl;
})();
this.loadingPromises.set(primaryUrl, loadingPromise);
try {
const result = await loadingPromise;
return result;
} finally {
this.loadingPromises.delete(primaryUrl);
}
}
}
const fileCache = new FileCache();
const FileContext = createContext();
export const useFileContext = () => useContext(FileContext);
export const FileProvider = ({ children }) => {
// Track basic per-URL loading state and resolved object URL for consumers
const [fileStates, setFileStates] = useState({}); // { [url]: { objectUrl: string|null, loadingState: 'unloaded'|'loading'|'loaded'|'error' } }
const getFileUrl = useCallback(
(url) => {
return fileStates[url]?.objectUrl || fileCache.get(url) || null;
},
[fileStates]
);
const loadFile = useCallback(async (url, mirrorUrl = null) => {
if (!url) return null;
// Fast path: cached
if (fileCache.isCached(url)) {
const objectUrl = fileCache.get(url);
setFileStates((prev) => ({
...prev,
[url]: { objectUrl, loadingState: "loaded" },
}));
return objectUrl;
}
// Transition to loading
setFileStates((prev) => ({
...prev,
[url]: {
objectUrl: prev[url]?.objectUrl || null,
loadingState: "loading",
},
}));
try {
const objectUrl = await fileCache.loadFile(url, mirrorUrl);
setFileStates((prev) => ({
...prev,
[url]: { objectUrl, loadingState: "loaded" },
}));
return objectUrl;
} catch (error) {
setFileStates((prev) => ({
...prev,
[url]: { objectUrl: null, loadingState: "error" },
}));
return null;
}
}, []);
const removeFile = useCallback(
(url) => {
const existing = fileStates[url]?.objectUrl;
if (existing) {
try {
URL.revokeObjectURL(existing);
} catch {
// ignore
}
}
fileCache.remove(url);
setFileStates((prev) => {
const next = { ...prev };
delete next[url];
return next;
});
},
[fileStates]
);
const clearFiles = useCallback(() => {
// Revoke any object URLs held in state, then clear cache
Object.values(fileStates).forEach((entry) => {
if (entry?.objectUrl) {
try {
URL.revokeObjectURL(entry.objectUrl);
} catch {
// ignore
}
}
});
setFileStates({});
fileCache.clear();
}, [fileStates]);
useEffect(() => {
return () => {
// Avoid setState in unmount cleanup to prevent update depth loops
try {
fileCache.clear();
} catch {
// ignore
}
};
}, []);
return (
<FileContext.Provider
value={{
fileStates, // map-like object keyed by URL
getFileUrl, // returns object URL or null
loadFile, // async, returns object URL or null
removeFile,
clearFiles,
}}
>
{children}
</FileContext.Provider>
);
};
FileProvider.propTypes = {
children: PropTypes.any,
};
export default FileContext;

View File

@ -0,0 +1,311 @@
import PropTypes from "prop-types";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
// Image object: { blurHash: string, url: string, mirrorUrl?: string }
const ImageContext = createContext();
export const useImageContext = () => useContext(ImageContext);
class ImageCache {
constructor() {
this.cache = new Map();
this.maxCacheSize = 50;
this.cacheOrder = [];
this.loadingPromises = new Map(); // Track ongoing loading operations
}
getCacheKey(url) {
return url;
}
isCached(url) {
return this.cache.has(this.getCacheKey(url));
}
get(url) {
const key = this.getCacheKey(url);
if (this.cache.has(key)) {
const index = this.cacheOrder.indexOf(key);
if (index > -1) this.cacheOrder.splice(index, 1);
this.cacheOrder.push(key);
return this.cache.get(key);
}
return null;
}
set(url, base64) {
const key = this.getCacheKey(url);
if (this.cache.has(key)) {
const index = this.cacheOrder.indexOf(key);
if (index > -1) this.cacheOrder.splice(index, 1);
}
this.cache.set(key, base64);
this.cacheOrder.push(key);
this.evictIfNeeded();
}
evictIfNeeded() {
while (this.cache.size > this.maxCacheSize) {
const oldestKey = this.cacheOrder.shift();
if (oldestKey && this.cache.has(oldestKey)) {
this.cache.delete(oldestKey);
}
}
}
async loadImage(primaryUrl, fallbackUrl = null) {
// Check cache using primary URL as key
const cached = this.get(primaryUrl);
if (cached) {
console.log(`[ImageCache] Using cached image: ${primaryUrl}`);
return cached;
}
// Check if this image is already being loaded
const key = this.getCacheKey(primaryUrl);
if (this.loadingPromises.has(key)) {
console.log(
`[ImageCache] Image already loading, waiting for existing promise: ${primaryUrl}`
);
return this.loadingPromises.get(key);
}
console.log(
`[ImageCache] Starting to load image: ${primaryUrl}${
fallbackUrl ? ` with fallback: ${fallbackUrl}` : ""
}`
);
// Create a new loading promise
const loadingPromise = this._performImageLoadWithFallback(
primaryUrl,
fallbackUrl
);
this.loadingPromises.set(key, loadingPromise);
try {
const result = await loadingPromise;
console.log(`[ImageCache] Successfully loaded image: ${primaryUrl}`);
return result;
} finally {
// Clean up the loading promise when done
this.loadingPromises.delete(key);
}
}
async _blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
async _performImageLoadWithFallback(primaryUrl, fallbackUrl) {
// Try mirror URL first if available
if (fallbackUrl) {
try {
console.log(`[ImageCache] Trying mirror URL first: ${fallbackUrl}`);
const response = await fetch(fallbackUrl);
if (!response.ok) {
throw new Error(`Mirror URL HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const base64 = await this._blobToBase64(blob);
this.set(primaryUrl, base64); // Cache using primary URL as key
console.log(
`[ImageCache] Successfully loaded from mirror URL: ${fallbackUrl}`
);
return base64;
} catch (error) {
console.warn(`[ImageCache] Mirror URL failed: ${fallbackUrl}`, error);
console.log(`[ImageCache] Falling back to primary URL: ${primaryUrl}`);
}
}
// Fallback to primary URL
try {
const response = await fetch(primaryUrl);
if (!response.ok)
throw new Error(`Primary URL HTTP error! status: ${response.status}`);
const blob = await response.blob();
const base64 = await this._blobToBase64(blob);
this.set(primaryUrl, base64);
console.log(
`[ImageCache] Successfully loaded from primary URL: ${primaryUrl}`
);
return base64;
} catch (error) {
console.error(`[ImageCache] Both URLs failed for: ${primaryUrl}`, error);
throw error;
}
}
async _performImageLoad(url) {
try {
const response = await fetch(url);
if (!response.ok)
throw new Error(`HTTP error! status: ${response.status}`);
const blob = await response.blob();
const base64 = await this._blobToBase64(blob);
this.set(url, base64);
return base64;
} catch (error) {
console.error(`Failed to load image: ${url}`, error);
throw error;
}
}
async preloadImages(images) {
// images: [{ url, mirrorUrl?, ... }]
const results = new Map();
const promises = images.map(async (image) => {
try {
const blobUrl = await this.loadImage(image.url, image.mirrorUrl);
results.set(image.url, blobUrl);
} catch (error) {
results.set(image.url, null);
}
});
await Promise.allSettled(promises);
return results;
}
clear() {
this.cache.clear();
this.cacheOrder = [];
this.loadingPromises.clear();
}
remove(url) {
const key = this.getCacheKey(url);
if (this.cache.has(key)) {
this.cache.delete(key);
const index = this.cacheOrder.indexOf(key);
if (index > -1) this.cacheOrder.splice(index, 1);
}
// Also clean up any ongoing loading promise
this.loadingPromises.delete(key);
}
}
const imageCache = new ImageCache();
export const ImageProvider = ({ children }) => {
const [imageObjects, setImageObjects] = useState([]);
const [allImagesLoaded, setAllImagesLoaded] = useState(false);
const loadImages = useCallback((images) => {
// images: [{ blurHash, url, mirrorUrl? }]
// Only initialize image objects with metadata, don't load them yet
if (!images || images.length === 0) {
setImageObjects([]);
setAllImagesLoaded(true);
return;
}
// Initialize image objects with unloaded state (images will be loaded on demand)
const initialImageObjects = images.map((img) => ({
src: img.url,
blurHash: img.blurHash,
mirrorUrl: img.mirrorUrl,
blob: null,
loadingState: "unloaded",
}));
setImageObjects(initialImageObjects);
setAllImagesLoaded(true); // Set to true since we're not preloading
}, []);
const loadIndividualImage = useCallback(
async (url) => {
console.log(`[ImageProvider] loadIndividualImage called for: ${url}`);
const imageObj = imageObjects.find((img) => img.src === url);
// If image object doesn't exist, we can't load it
if (!imageObj) {
console.log(`[ImageProvider] Image object not found: ${url}`);
return;
}
// Check if already loaded or currently loading
if (
imageObj.loadingState === "loaded" ||
imageObj.loadingState === "loading"
) {
console.log(
`[ImageProvider] Image already ${imageObj.loadingState}: ${url}`
);
return; // Already loading or loaded
}
// Check if image is already cached
if (imageCache.isCached(url)) {
console.log(
`[ImageProvider] Image found in cache, updating state: ${url}`
);
const base64 = imageCache.get(url);
setImageObjects((prev) =>
prev.map((img) =>
img.src === url
? { ...img, blob: base64, loadingState: "loaded" }
: img
)
);
return;
}
console.log(`[ImageProvider] Starting to load individual image: ${url}`);
// Update loading state
setImageObjects((prev) =>
prev.map((img) =>
img.src === url ? { ...img, loadingState: "loading" } : img
)
);
try {
const mirrorUrl = imageObj.mirrorUrl;
const base64 = await imageCache.loadImage(url, mirrorUrl);
setImageObjects((prev) =>
prev.map((img) =>
img.src === url
? { ...img, blob: base64, loadingState: "loaded" }
: img
)
);
} catch (error) {
console.error(`[ImageProvider] Failed to load image: ${url}`, error);
setImageObjects((prev) =>
prev.map((img) =>
img.src === url ? { ...img, loadingState: "error" } : img
)
);
}
},
[imageObjects]
);
useEffect(() => {
// Cleanup on unmount
return () => {
setImageObjects([]);
setAllImagesLoaded(false);
imageCache.clear();
};
}, []);
return (
<ImageContext.Provider
value={{
imageObjects, // [{ src, blurHash, mirrorUrl, blob, loadingState }]
allImagesLoaded,
loadIndividualImage,
loadImages, // function to load images
}}
>
{children}
</ImageContext.Provider>
);
};
ImageProvider.propTypes = {
children: PropTypes.any,
};
export default ImageContext;

View File

@ -0,0 +1,93 @@
import PropTypes from "prop-types";
import { createContext, useContext, useState } from "react";
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
import Keycloak from "keycloak-js";
// Initialize Keycloak
const keycloak = new Keycloak({
url: "https://auth.tombutcher.work", // Your Keycloak server
realm: "master", // Your Keycloak realm
clientId: "2025-web-client", // Your Keycloak client ID
});
const KeycloakContext = createContext(null);
const initKeycloak = async () => {
try {
const authenticated = await keycloak.init({
onLoad: "check-sso",
silentCheckSsoRedirectUri:
window.location.origin + "/silent-check-sso.html",
silentCheckSsoFallback: false,
checkLoginIframe: false,
});
return {
isAuthenticated: authenticated,
user: authenticated
? {
username: keycloak.tokenParsed?.preferred_username,
email: keycloak.tokenParsed?.email,
firstName: keycloak.tokenParsed?.given_name,
lastName: keycloak.tokenParsed?.family_name,
roles: keycloak.tokenParsed?.realm_access?.roles || [],
}
: null,
};
} catch (error) {
console.warn("Keycloak init failed", error);
return {
isAuthenticated: false,
user: null,
};
}
};
const KeycloakStateProvider = ({ children }) => {
const { data, isLoading } = useQuery({
queryKey: ["keycloak-init"],
queryFn: initKeycloak,
staleTime: Infinity,
cacheTime: Infinity,
retry: false,
refetchOnWindowFocus: false,
});
return (
<KeycloakContext.Provider
value={{
keycloak,
isAuthenticated: data?.isAuthenticated ?? false,
user: data?.user ?? null,
loading: isLoading,
}}
>
{children}
</KeycloakContext.Provider>
);
};
KeycloakStateProvider.propTypes = {
children: PropTypes.any,
};
export const KeycloakProvider = ({ children }) => {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<KeycloakStateProvider>{children}</KeycloakStateProvider>
</QueryClientProvider>
);
};
KeycloakProvider.propTypes = {
children: PropTypes.any,
};
// eslint-disable-next-line react-refresh/only-export-components
export const useKeycloak = () => useContext(KeycloakContext);

View File

@ -0,0 +1,196 @@
import { createContext, useContext, useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { useMediaQuery } from "react-responsive";
import DownloadFileIcon from "../icons/DownloadFileIcon";
import CVDownloadPopupMenu from "../components/Menu/CVDownloadPopupMenu";
import WebsiteSelectorIcon from "../icons/WebsiteSelectorIcon";
import WebsiteSelectorPopupMenu from "../components/Menu/WebsiteSelectorPopupMenu";
// Create the context
const MenuContext = createContext();
export const useMenu = () => useContext(MenuContext);
export const MenuProvider = ({
children,
pages = [],
currentPageSlug = "",
cvs = [],
}) => {
const [menuVisible, setMenuVisible] = useState(false);
const [activeSlug, setActiveSlug] = useState(currentPageSlug);
const [shouldRender, setShouldRender] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const [cvDownloadPopupVisible, setCvDownloadPopupVisible] = useState(false);
const [websiteSelectorPopupVisible, setWebsiteSelectorPopupVisible] =
useState(false);
const downloadCvButtonRef = useRef(null);
const websiteSelectorButtonRef = useRef(null);
const menuPopupRef = useRef(null);
const navigate = useNavigate();
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
useEffect(() => {
if (menuVisible) {
setShouldRender(true);
setIsExiting(false);
} else if (shouldRender) {
setIsExiting(true);
setCvDownloadPopupVisible(false); // Close CV download popup when menu closes
setWebsiteSelectorPopupVisible(false); // Close website selector popup when menu closes
const timer = setTimeout(() => {
setShouldRender(false);
setIsExiting(false);
}, 800); // Match animation duration
return () => clearTimeout(timer);
}
}, [menuVisible, shouldRender]);
// Handle click outside menu
useEffect(() => {
if (!shouldRender || isExiting) return;
const handleClickOutside = (event) => {
// Don't close if clicking on the menu button (hamburger) - it handles its own toggle
const menuButton = event.target.closest(".tb-header-button-menu");
if (menuButton) {
return;
}
// Check if click is outside the menu popup
if (
menuPopupRef.current &&
!menuPopupRef.current.contains(event.target)
) {
// Don't close if clicking on CV download popup or its button
const cvPopup = document.querySelector(".tb-cv-download-popup");
const websiteSelectorPopup = document.querySelector(
".tb-website-selector-popup"
);
if (
(!cvPopup ||
(!cvPopup.contains(event.target) &&
!downloadCvButtonRef.current?.contains(event.target))) &&
(!websiteSelectorPopup ||
(!websiteSelectorPopup.contains(event.target) &&
!websiteSelectorButtonRef.current?.contains(event.target)))
) {
setMenuVisible(false);
}
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [shouldRender, isExiting]);
return (
<MenuContext.Provider
value={{ menuVisible, setMenuVisible, setActiveSlug }}
>
{children}
{/* Fullscreen menu overlay */}
{shouldRender && (
<div
ref={menuPopupRef}
className={`tb-menu-popup${
isLargeMobile ? " tb-mobile-menu-popup" : ""
} ${
isExiting ? "tb-menu-popup-animated-exit" : "tb-menu-popup-animated"
}`}
>
<div
className={`tb-menu-popup-container${
isLargeMobile ? " tb-mobile-menu-popup-container" : ""
}`}
>
<ul className="tb-menu-nav">
{pages
.filter((page) => page.pageType === "landingPage")
.map((page) => {
return (
<li key={page.slug}>
<button
onClick={() => {
navigate(page.slug);
setMenuVisible(false);
}}
className={`tb-menu-nav-item${
page.slug === activeSlug ||
page.slug === currentPageSlug
? " tb-menu-nav-item-active"
: ""
}`}
dangerouslySetInnerHTML={{
__html: `${page.name || page.slug}${page.icon || ""}`,
}}
/>
</li>
);
})}
</ul>
<div className="tb-menu-popup-footer">
<button
ref={websiteSelectorButtonRef}
className={`tb-button tb-menu-popup-button${
websiteSelectorPopupVisible
? " tb-menu-popup-button-active"
: ""
}`}
onClick={() =>
setWebsiteSelectorPopupVisible(!websiteSelectorPopupVisible)
}
>
2026
<WebsiteSelectorIcon />
</button>
<div className="tb-menu-popup-divider" />
<button
ref={downloadCvButtonRef}
className={`tb-button tb-menu-popup-button${
cvDownloadPopupVisible ? " tb-menu-popup-button-active" : ""
}`}
onClick={() =>
setCvDownloadPopupVisible(!cvDownloadPopupVisible)
}
>
Download CV
<DownloadFileIcon />
</button>
</div>
</div>
</div>
)}
{menuVisible && (
<>
<WebsiteSelectorPopupMenu
isVisible={websiteSelectorPopupVisible}
onClose={() => setWebsiteSelectorPopupVisible(false)}
buttonRef={websiteSelectorButtonRef}
/>
<CVDownloadPopupMenu
isVisible={cvDownloadPopupVisible}
onClose={() => setCvDownloadPopupVisible(false)}
buttonRef={downloadCvButtonRef}
cvs={cvs}
/>
</>
)}
</MenuContext.Provider>
);
};
MenuProvider.propTypes = {
children: PropTypes.node.isRequired,
currentPageSlug: PropTypes.string,
pages: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
label: PropTypes.string, // optional display label
})
),
cvs: PropTypes.array,
};

View File

@ -0,0 +1,34 @@
import PropTypes from "prop-types";
import { createContext, useContext, useState, useEffect } from "react";
// Create context
const ProjectsContext = createContext({
projects: [],
setProjects: () => {},
});
// Provider
export const ProjectsProvider = ({ children, initialProjects = [] }) => {
const [projects, setProjects] = useState(initialProjects);
// Update projects whenever initialProjects changes
useEffect(() => {
if (initialProjects && initialProjects.length > 0) {
setProjects(initialProjects);
}
}, [initialProjects]);
return (
<ProjectsContext.Provider value={{ projects, setProjects }}>
{children}
</ProjectsContext.Provider>
);
};
ProjectsProvider.propTypes = {
children: PropTypes.any,
initialProjects: PropTypes.array,
};
// Hook for consuming
export const useProjects = () => useContext(ProjectsContext);

View File

@ -0,0 +1,32 @@
import { createContext, useContext, useEffect, useState } from "react";
import PropTypes from "prop-types";
const SettingsContext = createContext(null);
export const SettingsProvider = ({ settings: initialSettings, children }) => {
const [settings, setSettings] = useState(initialSettings);
// Update internal state whenever the prop changes
useEffect(() => {
setSettings(initialSettings);
}, [initialSettings]);
return (
<SettingsContext.Provider value={settings}>
{children}
</SettingsContext.Provider>
);
};
SettingsProvider.propTypes = {
settings: PropTypes.object.isRequired,
children: PropTypes.node.isRequired,
};
export const useSettingsContext = () => {
const settings = useContext(SettingsContext);
if (!settings) {
throw new Error("useSettingsContext must be used inside SettingsProvider");
}
return settings;
};

View File

@ -0,0 +1,52 @@
import PropTypes from "prop-types";
import { createContext, useContext, useEffect, useState } from "react";
// Create context
const ThemeContext = createContext({
theme: null,
});
// Provider
export const ThemeProvider = ({ children, currentTheme }) => {
const [theme, setTheme] = useState(currentTheme);
// Update theme whenever currentTheme changes
useEffect(() => {
if (currentTheme) {
setTheme(currentTheme);
}
}, [currentTheme]);
// Apply theme to document
useEffect(() => {
if (theme) {
document.body.style.backgroundColor = theme.backgroundColor || "";
void document.body.offsetHeight;
// Set global CSS vars
document.documentElement.style.setProperty(
"--tb-textColor",
theme.textColor || "#000000"
);
document.documentElement.style.setProperty(
"--tb-backgroundColor",
theme.backgroundColor || "#ffffff"
);
}
return () => {
document.body.classList.remove("tb-body-bg");
};
}, [theme]);
return (
<ThemeContext.Provider value={{ theme }}>{children}</ThemeContext.Provider>
);
};
ThemeProvider.propTypes = {
children: PropTypes.any,
currentTheme: PropTypes.object.isRequired, // The theme object
};
// Hook for consuming
export const useTheme = () => useContext(ThemeContext);

View File

@ -0,0 +1,336 @@
import PropTypes from "prop-types";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
} from "react";
// Simple video cache that stores object URLs (from Blobs)
class VideoCache {
constructor() {
this.cache = new Map(); // url -> objectUrl
this.loadingPromises = new Map(); // url -> Promise<string>
}
isCached(url) {
return this.cache.has(url);
}
get(url) {
return this.cache.get(url) || null;
}
set(url, objectUrl) {
// Revoke old object URL if overwriting
if (this.cache.has(url)) {
const oldUrl = this.cache.get(url);
if (oldUrl && oldUrl !== objectUrl) {
try {
URL.revokeObjectURL(oldUrl);
} catch {
// ignore
}
}
}
this.cache.set(url, objectUrl);
}
remove(url) {
if (this.cache.has(url)) {
const objectUrl = this.cache.get(url);
try {
URL.revokeObjectURL(objectUrl);
} catch {
// ignore
}
this.cache.delete(url);
}
this.loadingPromises.delete(url);
}
clear() {
for (const [, objectUrl] of this.cache.entries()) {
try {
URL.revokeObjectURL(objectUrl);
} catch {
// ignore
}
}
this.cache.clear();
this.loadingPromises.clear();
}
async _fetchToObjectUrl(url, onProgress) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Stream to track progress when possible
const contentLengthHeader = response.headers.get("content-length");
const totalBytes = contentLengthHeader
? parseInt(contentLengthHeader, 10)
: null;
if (response.body && typeof response.body.getReader === "function") {
const reader = response.body.getReader();
const chunks = [];
let loadedBytes = 0;
// Notify start
if (onProgress) {
try {
onProgress(totalBytes ? 0 : null, { loaded: 0, total: totalBytes });
} catch {
// ignore
}
}
let doneReading = false;
while (!doneReading) {
const { done, value } = await reader.read();
if (done) {
doneReading = true;
break;
}
chunks.push(value);
loadedBytes += value.length;
if (onProgress) {
try {
const ratio = totalBytes
? Math.min(1, loadedBytes / totalBytes)
: null;
onProgress(ratio, { loaded: loadedBytes, total: totalBytes });
} catch {
// ignore
}
}
}
const blob = new Blob(chunks, {
type:
response.headers.get("content-type") || "application/octet-stream",
});
if (onProgress) {
try {
const finalTotal = totalBytes ?? blob.size;
onProgress(1, { loaded: finalTotal, total: finalTotal });
} catch {
// ignore
}
}
return URL.createObjectURL(blob);
}
// Fallback: no streaming support; cannot measure progress
const blob = await response.blob();
if (onProgress) {
try {
onProgress(1, { loaded: blob.size, total: blob.size });
} catch {
// ignore
}
}
return URL.createObjectURL(blob);
}
async loadVideo(primaryUrl, mirrorUrl = null, onProgress) {
// Use cache if present
const cached = this.get(primaryUrl);
if (cached) {
return cached;
}
// Reuse in-flight request if present
if (this.loadingPromises.has(primaryUrl)) {
return this.loadingPromises.get(primaryUrl);
}
// Compose a single loading promise and store it to dedupe concurrent loads
const loadingPromise = (async () => {
// Try mirror first if provided
if (mirrorUrl) {
try {
const objUrl = await this._fetchToObjectUrl(mirrorUrl, onProgress);
this.set(primaryUrl, objUrl); // cache under primary URL key
return objUrl;
} catch {
// fall through to primary
}
}
const objUrl = await this._fetchToObjectUrl(primaryUrl, onProgress);
this.set(primaryUrl, objUrl);
return objUrl;
})();
this.loadingPromises.set(primaryUrl, loadingPromise);
try {
const result = await loadingPromise;
return result;
} finally {
this.loadingPromises.delete(primaryUrl);
}
}
}
const videoCache = new VideoCache();
const VideoContext = createContext();
export const useVideoContext = () => useContext(VideoContext);
export const VideoProvider = ({ children }) => {
// Track basic per-URL loading state and resolved object URL for consumers
const [videoStates, setVideoStates] = useState({}); // { [url]: { objectUrl, loadingState, progress?, loadedBytes?, totalBytes? } }
const getVideoUrl = useCallback(
(url) => {
return videoStates[url]?.objectUrl || videoCache.get(url) || null;
},
[videoStates]
);
const loadVideo = useCallback(async (url, mirrorUrl = null) => {
if (!url) return null;
// Fast path: cached
if (videoCache.isCached(url)) {
const objectUrl = videoCache.get(url);
setVideoStates((prev) => ({
...prev,
[url]: {
objectUrl,
loadingState: "loaded",
progress: 1,
loadedBytes: null,
totalBytes: null,
},
}));
return objectUrl;
}
// Transition to loading
setVideoStates((prev) => ({
...prev,
[url]: {
objectUrl: prev[url]?.objectUrl || null,
loadingState: "loading",
progress: 0,
loadedBytes: 0,
totalBytes: null,
},
}));
try {
const objectUrl = await videoCache.loadVideo(
url,
mirrorUrl,
(progressRatio, bytes) => {
setVideoStates((prev) => ({
...prev,
[url]: {
objectUrl: prev[url]?.objectUrl || null,
loadingState: "loading",
progress:
typeof progressRatio === "number"
? progressRatio
: prev[url]?.progress ?? null,
loadedBytes:
typeof bytes?.loaded === "number"
? bytes.loaded
: prev[url]?.loadedBytes ?? null,
totalBytes:
typeof bytes?.total === "number"
? bytes.total
: prev[url]?.totalBytes ?? null,
},
}));
}
);
setVideoStates((prev) => ({
...prev,
[url]: {
objectUrl,
loadingState: "loaded",
progress: 1,
loadedBytes: prev?.[url]?.loadedBytes ?? null,
totalBytes: prev?.[url]?.totalBytes ?? null,
},
}));
return objectUrl;
} catch (error) {
setVideoStates((prev) => ({
...prev,
[url]: {
objectUrl: null,
loadingState: "error",
progress: null,
loadedBytes: null,
totalBytes: null,
},
}));
return null;
}
}, []);
const removeVideo = useCallback(
(url) => {
const existing = videoStates[url]?.objectUrl;
if (existing) {
try {
URL.revokeObjectURL(existing);
} catch {
// ignore
}
}
videoCache.remove(url);
setVideoStates((prev) => {
const next = { ...prev };
delete next[url];
return next;
});
},
[videoStates]
);
const clearVideos = useCallback(() => {
// Revoke any object URLs held in state, then clear cache
Object.values(videoStates).forEach((entry) => {
if (entry?.objectUrl) {
try {
URL.revokeObjectURL(entry.objectUrl);
} catch {
// ignore
}
}
});
setVideoStates({});
videoCache.clear();
}, [videoStates]);
useEffect(() => {
return () => {
// Avoid setState in unmount cleanup to prevent update depth loops
try {
videoCache.clear();
} catch {
// ignore
}
};
}, []);
return (
<VideoContext.Provider
value={{
videoStates, // map-like object keyed by URL
getVideoUrl, // returns object URL or null
loadVideo, // async, returns object URL or null
removeVideo,
clearVideos,
}}
>
{children}
</VideoContext.Provider>
);
};
VideoProvider.propTypes = {
children: PropTypes.any,
};
export default VideoContext;

View File

@ -0,0 +1,7 @@
import AtSymbolIconSvg from "../../assets/atsymbolicon.svg?react";
const AtSymbolIcon = () => {
return <AtSymbolIconSvg />;
};
export default AtSymbolIcon;

7
src/icons/CheckIcon.jsx Normal file
View File

@ -0,0 +1,7 @@
import CheckIconSvg from "../../assets/checkicon.svg?react";
const CheckIcon = () => {
return <CheckIconSvg />;
};
export default CheckIcon;

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