Initial Commit
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=https://api.thehideoutltd.com
|
||||
VITE_TURNSTILE_KEY=0x4AAAAAAB2uebWFPXaK8spB
|
||||
2
.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_URL=https://thehideout.tombutcher.work/api
|
||||
VITE_TURNSTILE_KEY=0x4AAAAAAB2dBq6i8m4kYzDm
|
||||
18
.gitignore
vendored
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 |
10
assets/downloadfileicon.svg
Normal 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
@ -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 |
7
assets/exitfullscreenicon.svg
Normal 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
@ -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 |
9
assets/fullscreenicon.svg
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 |
13
assets/websiteselectoricon.svg
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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 |
BIN
public/assets/favicon192.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/assets/grain-texture.png
Normal file
|
After Width: | Height: | Size: 762 KiB |
6
public/assets/grain.svg
Normal 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
@ -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
@ -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"
|
||||
}
|
||||
13
public/silent-check-sso.html
Normal 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
@ -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
@ -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;
|
||||
23
src/components/Blogs/BlogList.jsx
Normal 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;
|
||||
29
src/components/Blogs/BlogListItem.jsx
Normal 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;
|
||||
92
src/components/Blogs/BlogPage.jsx
Normal 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;
|
||||
48
src/components/Buttons/AccountButton.jsx
Normal 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;
|
||||
29
src/components/Buttons/MenuButton.jsx
Normal 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;
|
||||
54
src/components/Buttons/ShareButton.jsx
Normal 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;
|
||||
20
src/components/Buttons/VisitButton.jsx
Normal 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;
|
||||
622
src/components/ContentRenderer.jsx
Normal 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;
|
||||
22
src/components/Experience/CompaniesList.jsx
Normal 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;
|
||||
39
src/components/Experience/CompaniesListItem.jsx
Normal 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;
|
||||
154
src/components/Experience/ExperiencePage.jsx
Normal 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
@ -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;
|
||||
148
src/components/Forms/ContactForm.jsx
Normal 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;
|
||||
186
src/components/Forms/Turnstile.jsx
Normal 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
@ -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;
|
||||
38
src/components/HeaderLogo.jsx
Normal 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
@ -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;
|
||||
58
src/components/LoadingModal.jsx
Normal 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;
|
||||
202
src/components/Menu/CVDownloadPopupMenu.jsx
Normal 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;
|
||||
140
src/components/Menu/CVVersionsSelectorPopup.jsx
Normal 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;
|
||||
201
src/components/Menu/SharePopupMenu.jsx
Normal 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;
|
||||
142
src/components/Menu/WebsiteSelectorPopupMenu.jsx
Normal 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
@ -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;
|
||||
177
src/components/ParticlesBackground.jsx
Normal 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);
|
||||
39
src/components/Projects/ProjectCard.jsx
Normal 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;
|
||||
21
src/components/Projects/ProjectCards.jsx
Normal 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;
|
||||
134
src/components/Projects/ProjectPage.jsx
Normal 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;
|
||||
22
src/components/Projects/ProjectStatus.jsx
Normal 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;
|
||||
97
src/components/SubPage.jsx
Normal 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
@ -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;
|
||||
149
src/components/VideoVolume.jsx
Normal 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;
|
||||
233
src/contexts/AccountContext.jsx
Normal 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,
|
||||
};
|
||||
29
src/contexts/ActionContext.jsx
Normal 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);
|
||||
34
src/contexts/BlogsContext.jsx
Normal 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);
|
||||
34
src/contexts/CompaniesContext.jsx
Normal 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);
|
||||
232
src/contexts/FileContext.jsx
Normal 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;
|
||||
311
src/contexts/ImageContext.jsx
Normal 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;
|
||||
93
src/contexts/KeycloakContext.jsx
Normal 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);
|
||||
196
src/contexts/MenuContext.jsx
Normal 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,
|
||||
};
|
||||
34
src/contexts/ProjectsContext.jsx
Normal 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);
|
||||
32
src/contexts/SettingsContext.jsx
Normal 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;
|
||||
};
|
||||
52
src/contexts/ThemeContext.jsx
Normal 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);
|
||||
336
src/contexts/VideoContext.jsx
Normal 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;
|
||||
7
src/icons/AtSymbolIcon.jsx
Normal 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
@ -0,0 +1,7 @@
|
||||
import CheckIconSvg from "../../assets/checkicon.svg?react";
|
||||
|
||||
const CheckIcon = () => {
|
||||
return <CheckIconSvg />;
|
||||
};
|
||||
|
||||
export default CheckIcon;
|
||||