Initial commit

This commit is contained in:
Tom Butcher 2025-10-11 19:04:27 +01:00
commit 6b9e719967
66 changed files with 16037 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

2
.env Normal file
View File

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

2
.env.development Normal file
View File

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

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
dist
.wrangler

7
assets/airbnblogo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 4.3 KiB

17
assets/bookingcomlogo.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 2.0 KiB

30
assets/checkicon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

10
assets/closeicon.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 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-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(0.877296,0,0,0.877296,3.85308,4)">
<path d="M64,0L0.167,63.833" style="fill:none;stroke-width:7.98px;"/>
<g transform="matrix(-1,0,0,1,64.1675,0)">
<path d="M64,0L0.167,63.833" style="fill:none;stroke-width:7.98px;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 766 B

8
assets/lefticon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

29
assets/loadingicon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 791 B

35
assets/menuicon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 880 B

10
assets/righticon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

21
assets/scrollicon.svg Normal file
View File

@ -0,0 +1,21 @@
<?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 height="35px" viewBox="0 0 72 120" 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.779005,0,0,0.345762,-153.293,-560.61)">
<path d="M238.374,1697.93L238.374,1721.67C238.374,1726.99 240.291,1731.31 242.653,1731.31C245.014,1731.31 246.932,1726.99 246.932,1721.67L246.932,1697.93C246.932,1692.61 245.014,1688.29 242.653,1688.29C240.291,1688.29 238.374,1692.61 238.374,1697.93Z" style="fill:var(--th-textColor);">
<animateTransform
attributeName="transform"
type="translate"
values="0,0; 0,60; 0,0"
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>
<g transform="matrix(0.779005,0,0,0.779005,-153.293,-1296.23)">
<path d="M288.526,1709.82C288.526,1684.51 267.971,1663.95 242.653,1663.95C217.335,1663.95 196.78,1684.51 196.78,1709.82L196.78,1771.12C196.78,1796.43 217.335,1816.99 242.653,1816.99C267.971,1816.99 288.526,1796.43 288.526,1771.12C288.526,1771.12 288.526,1709.82 288.526,1709.82ZM280.182,1709.82L280.182,1771.12C280.182,1791.83 263.366,1808.65 242.653,1808.65C221.94,1808.65 205.124,1791.83 205.124,1771.12C205.124,1771.12 205.124,1709.82 205.124,1709.82C205.124,1689.11 221.94,1672.29 242.653,1672.29C263.366,1672.29 280.182,1689.11 280.182,1709.82Z" style="fill:var(--th-textColor);"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

38
eslint.config.js Normal file
View File

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

37
index.html Normal file
View File

@ -0,0 +1,37 @@
<!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="Airbnb co-hosting and property management."
/>
<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=Libre+Baskerville:ital,wght@0,400;0,700;1,400&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="/global.css" />
<link rel="stylesheet" href="/fonts.css" />
<link rel="manifest" href="/manifest.json" />
<title>The Hideout</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="th-root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

9
main.jsx Normal file
View File

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

72
package.json Normal file
View File

@ -0,0 +1,72 @@
{
"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",
"@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.8.1",
"antd": "^5.24.6",
"axios": "^1.6.0",
"blurhash": "^2.0.5",
"framer-motion": "^12.6.3",
"hamburger-react": "^2.5.2",
"keycloak-js": "^26.1.4",
"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",
"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"
}
}

BIN
public/.DS_Store vendored Normal file

Binary file not shown.

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

@ -0,0 +1,28 @@
<?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 id="Artboard1" transform="matrix(1.05263,0,0,2.60071,0,0)">
<rect x="0" y="0" width="1026" height="415.271" style="fill:none;"/>
<clipPath id="_clip1">
<rect x="0" y="0" width="1026" height="415.271"/>
</clipPath>
<g clip-path="url(#_clip1)">
<g id="Logo" transform="matrix(0.0904761,-5.60808e-17,-1.38558e-16,-0.03662,-355.3,504.839)">
<g transform="matrix(2.95313,-4.5225e-15,-8.04e-15,-5.25001,3927,13785.9)">
<path d="M3840,540C3840,241.966 3409.84,0 2880,0L960,0C430.162,0 0,241.966 0,540L0,1620C0,1918.03 430.162,2160 960,2160L2880,2160C3409.84,2160 3840,1918.03 3840,1620L3840,540Z" style="fill:rgb(131,11,13);"/>
</g>
<g transform="matrix(3.83175,-1.57772e-30,-1.57772e-30,3.83175,-32672,-26559.4)">
<g transform="matrix(0.828984,-3.53718e-32,3.53718e-32,0.828984,1876.27,-1505.29)">
<path d="M10500,13761C10385,13730 10166.9,13634.5 10148.9,13626.5C10131.9,13618.5 10074.9,13595.5 10022.9,13574.5C9970.94,13553.5 9795.44,13478.6 9764.44,13464.6L9709.44,13438.6L9712.44,13331.6C9715.44,13210.6 9714.44,13211.6 9798.44,13250.6C9822.44,13260.6 9998.94,13335.5 10059.9,13359.5C10120.9,13383.5 10378,13503 10521,13540C10626,13567 10780,13549 10902,13494C10951,13472 11056.5,13433.1 11193.5,13379.1C11339.5,13322.1 11370.5,13309.1 11449.5,13274.1C11469.5,13266.1 11488.5,13259.1 11492.5,13259.1C11496.5,13259.1 11525.5,13247.1 11556.5,13232.1C11588.5,13218.1 11616.5,13206.1 11620.5,13206.1C11623.5,13206.1 11640.5,13199.1 11659.5,13191.1C11677.5,13184.1 11708.5,13170.1 11729.5,13161.1C11749.5,13152.1 11789.5,13135.1 11818.5,13124.1C11847.5,13113.1 11902.5,13089.1 11941.5,13071.1C11981.5,13053.1 12015.8,13039.5 12018.5,13038.1C12100.2,12996.9 11859.8,13113 12196.8,12950C12285.8,12907 12362.8,12873 12367.8,12873C12373.8,12873 12377.8,12921 12377.8,12983L12377.8,13094L12259.8,13150C12153.1,13200.6 12111.5,13227.1 12086.5,13240.1L12038.5,13264.1L12038.5,13568.1C12038.5,13736.1 12037.5,13879.1 12036.5,13886.1C12034.5,13896.1 11997.5,13899.1 11878.5,13897.1L11723.5,13894.1L11720.5,13649.1L11718.5,13405.1L11689.5,13412.1C11673.5,13416.1 11599.5,13444.1 11524.5,13474.1C11306.5,13563.1 11215,13595 11120,13634C11024,13673 10932,13711 10915,13719C10864,13743 10680,13787 10633,13786C10609,13786 10549,13774 10500,13761Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.828984,-3.53718e-32,3.53718e-32,0.828984,7054.61,-611.996)">
<path d="M3927,11427C3927,10789 3929,10675 3943,10628C3961,10566 4002,10529 4069,10513C4130,10497 4306,10488 4384,10496L4452,10503L4452,10752L4390,10752C4302,10752 4277,10764 4263,10812C4255,10838 4253,10970 4255,11203L4258,11555L4299,11558C4392,11565 4444,11622 4450,11723L4454,11792L4254,11792L4251,11927C4248,12046 4244,12067 4225,12093C4186,12145 4146,12160 4032,12167L3927,12173L3927,11427Z" style="fill:white;fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.828984,-3.53718e-32,3.53718e-32,0.828984,7054.61,-611.996)">
<path d="M4512,12164C4508,12159 4505,11781 4505,11323L4505,10490L4670,10489L4835,10489L4832,10807C4829,11160 4838,11272 4878,11361C4929,11472 4991,11512 5114,11512C5240,11512 5316,11456 5357,11331C5378,11266 5380,11238 5384,10875L5388,10490L5713,10490L5709,10959C5706,11369 5703,11437 5687,11492C5634,11672 5537,11758 5345,11797C5244,11818 5163,11812 5073,11779C5004,11753 4922,11701 4878,11656C4861,11638 4844,11624 4840,11624C4836,11624 4832,11724 4831,11846C4828,12089 4824,12108 4767,12142C4735,12161 4526,12179 4512,12164Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

@ -0,0 +1,40 @@
<?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 1026 317" 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.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M10500,13761C10385,13730 9777,13525 9707,13494C9696,13488 9672,13479 9655,13473C9555,13440 8733,13121 8657,13087C8640,13080 8515,13028 8379,12972C8243,12917 8118,12865 8101,12857C8043,12831 7770,12716 7765,12716C7762,12716 7735,12704 7705,12691C7583,12636 7499,12600 7424,12570C7380,12553 7243,12495 7119,12442C6995,12389 6858,12332 6815,12315C6771,12297 6722,12277 6704,12269C6687,12261 6630,12238 6578,12217C6526,12196 6459,12167 6428,12153L6373,12127L6376,12020C6379,11899 6378,11900 6462,11939C6486,11949 6554,11978 6615,12002C6676,12026 6750,12057 6781,12071C6811,12085 6865,12108 6901,12123C6937,12138 6981,12157 6998,12165C7016,12172 7103,12209 7193,12247C7452,12354 8182,12662 8216,12678C8234,12686 8338,12729 8447,12773C8557,12818 8661,12860 8678,12868C8704,12880 9014,13002 9382,13146C9658,13253 10378,13503 10521,13540C10626,13567 10780,13549 10902,13494C10951,13472 11490,13252 11702,13167C11777,13137 11874,13099 11918,13081C11961,13064 12091,13012 12206,12966C12322,12920 12426,12879 12437,12874C12511,12842 12547,12827 12684,12773C12830,12716 12861,12703 12940,12668C12960,12660 12979,12653 12983,12653C12987,12653 13016,12641 13047,12626C13079,12612 13107,12600 13111,12600C13114,12600 13131,12593 13150,12585C13168,12578 13199,12564 13220,12555C13240,12546 13280,12529 13309,12518C13338,12507 13393,12483 13432,12465C13472,12447 13506,12432 13509,12432C13511,12432 13626,12383 13763,12322C14071,12188 14182,12137 14519,11974C14608,11931 14685,11897 14690,11897C14696,11897 14700,11945 14700,12007L14700,12118L14582,12174C14517,12204 14424,12249 14375,12272C14203,12353 14000,12446 13813,12528C13709,12573 13602,12621 13577,12634L13529,12658L13529,12962C13529,13130 13528,13273 13527,13280C13525,13290 13488,13293 13369,13291L13214,13288L13211,13043L13209,12799L13180,12806C13164,12810 13090,12838 13015,12868C12797,12957 12414,13112 12311,13153C12259,13174 12203,13197 12185,13204C12168,13211 12073,13249 11975,13288C11877,13326 11783,13365 11765,13372C11748,13380 11649,13420 11545,13461C11441,13502 11342,13542 11324,13550C11307,13558 11215,13595 11120,13634C11024,13673 10932,13711 10915,13719C10864,13743 10680,13787 10633,13786C10609,13786 10549,13774 10500,13761Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M3927,11427C3927,10789 3929,10675 3943,10628C3961,10566 4002,10529 4069,10513C4130,10497 4306,10488 4384,10496L4452,10503L4452,10752L4390,10752C4302,10752 4277,10764 4263,10812C4255,10838 4253,10970 4255,11203L4258,11555L4299,11558C4392,11565 4444,11622 4450,11723L4454,11792L4254,11792L4251,11927C4248,12046 4244,12067 4225,12093C4186,12145 4146,12160 4032,12167L3927,12173L3927,11427Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M4512,12164C4508,12159 4505,11781 4505,11323L4505,10490L4670,10489L4835,10489L4832,10807C4829,11160 4838,11272 4878,11361C4929,11472 4991,11512 5114,11512C5240,11512 5316,11456 5357,11331C5378,11266 5380,11238 5384,10875L5388,10490L5713,10490L5709,10959C5706,11369 5703,11437 5687,11492C5634,11672 5537,11758 5345,11797C5244,11818 5163,11812 5073,11779C5004,11753 4922,11701 4878,11656C4861,11638 4844,11624 4840,11624C4836,11624 4832,11724 4831,11846C4828,12089 4824,12108 4767,12142C4735,12161 4526,12179 4512,12164Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M7032,12164C7028,12159 7025,11781 7025,11323L7025,10490L7359,10490L7363,10891L7366,11293L7396,11359C7434,11440 7479,11481 7556,11504C7655,11532 7768,11503 7826,11434C7894,11353 7906,11262 7906,10839L7907,10490L8233,10490L8230,10965C8226,11428 8225,11442 8202,11513C8174,11597 8158,11624 8107,11680C8024,11768 7835,11826 7697,11805C7601,11790 7505,11746 7427,11679L7362,11624L7359,11842C7355,12082 7348,12112 7287,12143C7253,12161 7045,12177 7032,12164Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M8323,12163C8319,12159 8316,12100 8316,12031L8316,11907L8642,11907L8642,11986C8642,12128 8607,12159 8437,12166C8378,12168 8327,12167 8323,12163Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M9626,12164C9622,12159 9618,12035 9618,11887L9618,11619L9570,11673C9471,11787 9307,11835 9140,11799C9026,11774 8969,11744 8892,11665C8835,11607 8817,11580 8791,11512C8742,11384 8732,11320 8732,11135C8732,10946 8747,10864 8800,10759C8845,10671 8879,10629 8950,10577C9038,10512 9150,10478 9303,10471C9656,10456 9868,10594 9927,10880C9941,10948 9944,11048 9944,11498C9944,12097 9945,12085 9871,12133C9841,12153 9813,12159 9734,12165C9679,12169 9630,12168 9626,12164ZM9406,11507C9533,11480 9599,11375 9614,11177C9625,11033 9589,10890 9526,10822C9481,10774 9423,10752 9340,10752C9176,10752 9090,10846 9072,11043C9056,11218 9094,11381 9167,11451C9197,11480 9289,11518 9331,11518C9344,11518 9378,11514 9406,11507Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M13652,11401L13655,10630L13685,10585C13703,10558 13729,10534 13752,10524C13806,10502 13974,10488 14083,10496L14175,10503L14175,10752L14101,10752C14036,10752 14024,10755 14001,10778L13975,10804L13978,11179L13981,11555L14029,11562C14123,11575 14175,11639 14175,11739L14175,11792L13977,11792L13974,11925C13971,12046 13968,12063 13946,12094C13910,12144 13875,12157 13756,12165L13650,12173L13652,11401Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M6252,11803C5928,11751 5773,11513 5789,11093C5797,10880 5838,10752 5933,10640C6024,10535 6160,10474 6328,10462C6621,10442 6829,10563 6916,10802C6930,10839 6941,10873 6941,10878C6941,10892 6687,10891 6651,10878C6636,10872 6604,10851 6579,10831C6511,10775 6452,10752 6375,10752C6231,10752 6158,10827 6127,11007L6121,11046L6964,11046L6959,11196C6955,11284 6946,11371 6936,11408C6882,11607 6768,11730 6584,11786C6517,11806 6330,11815 6252,11803ZM6474,11520C6531,11494 6578,11435 6598,11362C6607,11329 6615,11296 6615,11289C6615,11280 6553,11277 6374,11277C6100,11277 6119,11269 6150,11369C6172,11440 6228,11504 6284,11524C6343,11544 6423,11543 6474,11520Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M10433,11797C10217,11752 10077,11612 10020,11386C10007,11333 10002,11269 10002,11141C10002,10943 10016,10870 10077,10745C10184,10524 10457,10414 10749,10475C10912,10508 11032,10599 11102,10742C11123,10785 11144,10836 11147,10855L11154,10890L11006,10887C10871,10884 10856,10881 10829,10859C10813,10846 10774,10818 10742,10796C10687,10758 10683,10757 10590,10757C10506,10757 10491,10760 10458,10783C10396,10827 10360,10885 10348,10962C10344,10988 10339,11017 10336,11028C10331,11045 10358,11046 10754,11049L11177,11051L11175,11167C11170,11454 11079,11648 10908,11739C10873,11758 10820,11780 10790,11788C10710,11810 10519,11815 10433,11797ZM10684,11520C10741,11494 10790,11436 10809,11370C10839,11269 10858,11277 10583,11277L10340,11277L10347,11316C10365,11416 10428,11500 10505,11527C10555,11544 10638,11541 10684,11520Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M11684,11802C11546,11778 11429,11711 11357,11613C11263,11485 11231,11345 11238,11094C11241,10982 11248,10904 11260,10862C11334,10594 11525,10458 11825,10458C12110,10459 12285,10575 12373,10825C12405,10913 12405,10918 12406,11125C12406,11313 12403,11343 12382,11418C12368,11464 12345,11526 12329,11555C12289,11629 12203,11710 12126,11748C12013,11804 11829,11826 11684,11802ZM11915,11502C11936,11493 11965,11476 11979,11463C12097,11355 12099,10947 11982,10822C11924,10759 11813,10734 11736,10766C11619,10815 11568,10928 11568,11141C11568,11333 11621,11459 11723,11504C11766,11523 11868,11522 11915,11502Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M8316,11792L8316,10490L8642,10490L8641,11091C8641,11675 8641,11693 8620,11727C8590,11777 8545,11792 8421,11792L8316,11792Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
<g transform="matrix(0.095238,-1.4585e-16,-1.4585e-16,-0.095238,-374,1312.95)">
<path d="M12487,11332C12493,10833 12497,10794 12555,10687C12590,10622 12635,10583 12725,10538C12834,10482 12905,10466 13030,10465C13161,10465 13255,10486 13364,10539C13449,10580 13494,10625 13531,10706C13581,10814 13587,10887 13587,11357L13587,11792L13474,11791C13381,11791 13355,11788 13328,11771C13267,11734 13267,11738 13262,11282C13256,10828 13256,10832 13197,10776C13160,10742 13117,10729 13039,10729C12942,10728 12877,10760 12844,10825C12821,10872 12821,10876 12821,11332L12821,11792L12482,11792L12487,11332Z" style="fill:rgb(94,8,9);fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

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

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

109
public/fonts.css Normal file
View File

@ -0,0 +1,109 @@
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 900;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 700;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 800;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: normal;
font-weight: 200;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 500;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 400;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 600;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 300;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 100;
src: url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.otf")
format("opentype");
}

1257
public/global.css Normal file

File diff suppressed because it is too large Load Diff

20
public/manifest.json Normal file
View File

@ -0,0 +1,20 @@
{
"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",
"theme_color": "#E800B6",
"background_color": "#2B0BFF"
}

View File

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

138
readme.md Normal file
View File

@ -0,0 +1,138 @@
# The Hideout UI
[![Build Status](https://ci.tombutcher.work/job/tombutcher.work/job/tombutcher-ui/job/main/badge/icon)](https://ci.tombutcher.work/job/tombutcher.work/job/tombutcher-ui/job/main/)
This is the front-end web application for **The Hideout** ([thehideoutltd.com](https://thehideoutltd.com)), built with **React.js** and hosted on **Cloudflare Pages**. The website showcases rental properties with beautiful image galleries, property details, booking integrations, and a contact form for inquiries.
## Tech Stack
- **React 19**: Modern JavaScript library for building user interfaces
- **Vite**: Fast build tool and development server
- **Ant Design**: UI component library for polished design
- **Framer Motion**: Animation library for smooth transitions
- **Cloudflare Pages**: Static site hosting with global CDN
- **Cloudflare Turnstile**: Bot protection for form submissions
- **Keycloak**: Authentication and authorization
- **SASS**: CSS preprocessor for advanced styling
## Features
- **Property Showcase**: Display rental properties with detailed information and features
- **Image Galleries**: Responsive image carousels with blurhash placeholders for smooth loading
- **Responsive Design**: Fully responsive layout that adapts to all screen sizes
- **Contact Form**: Secure contact form with Cloudflare Turnstile protection
- **Booking Integration**: Links to Airbnb and Booking.com for direct bookings
- **Authentication**: Keycloak integration for secure user management
- **Particle Effects**: Interactive background animations using tsParticles
- **Performance**: Optimized with lazy loading, caching, and modern web practices
## 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.
### Automated Deployment
This project uses Jenkins for CI/CD. The pipeline automatically:
1. Checks out the code
2. Installs dependencies
3. Builds the project
4. Deploys 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
```
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## 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/)

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

408
src/App.jsx Normal file
View File

@ -0,0 +1,408 @@
import PropTypes from "prop-types";
import { useState, useEffect, useRef } 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 Page from "./components/Page";
import Images from "./components/ImageSidebar";
import { ImageProvider, useImageContext } from "./contexts/ImageContext";
import LoadingModal from "./components/LoadingModal";
import { ActionProvider } from "./contexts/ActionContext";
import SubPage from "./components/SubPage";
import PropertyPage from "./components/PropertyPage";
import { MenuProvider } from "./contexts/MenuContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import {
SettingsProvider,
useSettingsContext,
} from "./contexts/SettingsContext";
const apiUrl = import.meta.env.VITE_API_URL;
// Component that handles image loading after API data is fetched
const AppContent = ({ pages, properties, images }) => {
const { loadImages } = useImageContext();
const [loadedOnce, setLoadedOnce] = useState(false);
const [currentPage, setCurrentPage] = useState({ pageType: "landingPage" });
const [currentTheme, setCurrentTheme] = useState();
const [currentProperty, setCurrentProperty] = useState(null);
const [currentPageIdx, setCurrentPageIdx] = useState(0);
const [contentVisible, setContentVisible] = useState(true);
const [subPageVisible, setSubPageVisible] = useState(false);
const locationRef = useRef();
const previousPageRef = useRef(null);
const currentPageRef = useRef(currentPage);
const landingPages = pages.filter((page) => page.pageType == "landingPage");
const navigate = useNavigate();
const location = useLocation();
const settings = useSettingsContext();
const isMobile = useMediaQuery({ maxWidth: 800 });
// Load images when they become available
useEffect(() => {
if (!loadedOnce && images && images.length > 0) {
loadImages(images);
setLoadedOnce(true);
}
}, [images, loadImages, loadedOnce]);
const setPageTitle = (name) => {
document.title = name ? `${name} - The Hideout` : "The Hideout";
};
// Handle direct URL navigation
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
locationRef.current = location.pathname;
if (Object.keys(settings).length == 0) {
return;
}
// Check if the URL matches /properties/:slug
if (location.pathname.startsWith("/properties/")) {
const propertySlug = location.pathname.split("/properties/")[1];
if (propertySlug != "") {
const property = properties.find((p) => p.slug === propertySlug);
if (property) {
// Set previousPage when navigating to a property
if (currentPageRef.current.pageType !== "subPage") {
previousPageRef.current = { ...currentPageRef.current };
}
setCurrentProperty(property);
setContentVisible(true);
if (currentProperty != null || currentPage.pageType == "subPage") {
setSubPageVisible(false);
setTimeout(() => {
setSubPageVisible(true);
}, 500);
} else {
setSubPageVisible(true);
}
setPageTitle(property.name);
} else {
navigate(settings.redirects["404"]);
setCurrentProperty(null); // property not found
}
return; // exit early since we're on a property page
}
}
setCurrentProperty(null);
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 };
}
setCurrentPage(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,
smooth: "easeInOutQuart",
containerId: "app-container",
});
});
} else {
setContentVisible(false);
if (currentPage.pageType == page.pageType) {
setSubPageVisible(false);
setTimeout(() => {
setSubPageVisible(true);
}, 500);
} else {
setSubPageVisible(true);
}
setTimeout(() => {
setContentVisible(true);
}, 600);
}
} 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
setCurrentPage(indexPage);
setCurrentPageIdx(0);
setPageTitle(indexPage.name);
navigate(`/${indexPage.slug}`, { replace: true });
}
}, [
location.pathname,
pages,
settings?.redirects?.index,
navigate,
settings,
properties,
currentPage.pageType,
currentProperty,
]);
// 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;
if (minDistance != 0) {
setContentVisible(false);
}
if (minDistance == 0) {
setContentVisible(true);
}
}
}
});
// 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]);
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]);
// Update currentPageRef whenever currentPage changes
useEffect(() => {
currentPageRef.current = currentPage;
}, [currentPage]);
// Set body background color to match current page's theme
useEffect(() => {
const theme = settings.themes.find(
(theme) => theme.name === currentPage?.theme
);
setCurrentTheme(theme);
}, [currentPage, settings.themes]);
// Handle subpage close with smart navigation
const handleSubPageClose = () => {
// 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("/");
}
};
return (
<ThemeProvider currentTheme={currentTheme}>
<MenuProvider pages={pages} currentPageSlug={currentPage?.slug}>
<div
className={
!isMobile
? "th-app-container"
: "th-app-container th-app-container-mobile"
}
id="app-container"
>
{landingPages.map((pageData, index) => (
<Element
key={index}
name={pageData.slug}
className={
!isMobile
? "th-page-wrapper"
: "th-page-wrapper th-page-wrapper-mobile"
}
data-name={pageData.slug}
>
<Page
pageData={pageData}
visible={
contentVisible && pageData.notionId == currentPage.notionId
}
theme={settings.themes.find(
(theme) => theme.name === pageData.theme
)}
themes={settings.themes}
isSubPage={false}
id={index}
/>
</Element>
))}
</div>
<SubPage
visible={currentPage.pageType == "subPage" && subPageVisible == true}
>
<Page
pageData={currentPage}
visible={contentVisible}
theme={settings.themes.find(
(theme) => theme.name === currentPage.theme
)}
themes={settings.themes}
isSubPage={true}
id={currentPageIdx}
showClose={true}
showMenu={false}
onClose={handleSubPageClose}
/>
</SubPage>
<SubPage visible={currentProperty != null && subPageVisible == true}>
<PropertyPage
propertyData={currentProperty}
visible={contentVisible}
onClose={() => {
navigate("/" + settings.redirects.properties);
}}
/>
</SubPage>
{isMobile && <div className="th-mobile-overlay" />}
{!isMobile && (
<Images
imageCollections={[...pages, ...properties]}
currentSlug={currentProperty?.slug || currentPage?.slug}
/>
)}
</MenuProvider>
</ThemeProvider>
);
};
AppContent.propTypes = {
images: PropTypes.shape({
length: PropTypes.number,
}),
pages: PropTypes.array.isRequired,
properties: PropTypes.array.isRequired,
themes: PropTypes.shape({
find: PropTypes.func,
}),
};
const App = () => {
const [pages, setPages] = useState([]);
const [properties, setProperties] = useState([]);
const [images, setImages] = useState([]);
const [settings, setSettings] = useState({
themes: [],
redirects: {},
branding: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const actions = {};
useEffect(() => {
const fetchPages = async () => {
try {
setLoading(true);
const response = await axios.get(`${apiUrl}/content`);
setPages(response.data.pages || response.data); // Handle both array and object with pages property
setProperties(response.data.properties || response.data);
setImages(response.data.images || response.data);
setSettings(response.data.settings || response.data);
} catch (err) {
setError(
err.response?.data?.message || err.message || "Failed to fetch pages"
);
console.error("Error fetching pages:", err);
} finally {
setLoading(false);
}
};
fetchPages();
}, []);
if (error) {
return (
<div className="th-error-container">
<Alert
message="Error Loading Pages"
description={error}
type="error"
showIcon
/>
</div>
);
}
return (
<SettingsProvider settings={settings}>
<ActionProvider onAction={actions}>
<ImageProvider>
<AppContent pages={pages} images={images} properties={properties} />
<LoadingModal visible={loading} />
</ImageProvider>
</ActionProvider>
</SettingsProvider>
);
};
export default App;

BIN
src/components/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,27 @@
import CloseIcon from "../icons/CloseIcon";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
const CloseButton = ({ onClick }) => {
const navigate = useNavigate();
const handleClick = () => {
if (onClick) {
onClick();
} else {
navigate(-1); // Goes back one page in the browser history
}
};
return (
<button className="th-header-button" onClick={handleClick}>
<CloseIcon />
</button>
);
};
CloseButton.propTypes = {
onClick: PropTypes.func,
};
export default CloseButton;

View File

@ -0,0 +1,127 @@
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";
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 handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
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="th-contact-form-wrapper">
<form onSubmit={handleSubmit} className="th-form">
<input
id="name"
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="th-form-input"
placeholder="your name"
disabled={loading}
/>
<input
id="email"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className="th-form-input"
placeholder="email"
disabled={loading}
/>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
rows="5"
className="th-form-input"
placeholder="write your message here..."
disabled={loading}
></textarea>
{/* Cloudflare Turnstile */}
<Turnstile
siteKey={turnstileKey}
theme="light"
ref={turnstileRef}
onVerify={(token) => setToken(token)}
/>
<div className="th-form-actions">
<button
type="submit"
className={"th-button"}
style={{ marginTop: 0 }}
disabled={loading}
>
{loading ? <LoadingIcon /> : <CheckIcon />}
Send Message
</button>
{error && <p className="th-form-error">{error.message}</p>}
</div>
</form>
</div>
);
};
export default ContactForm;

View File

@ -0,0 +1,185 @@
import ContactForm from "./ContactForm";
import PropertyCards from "./PropertyCards";
import { useNavigate } from "react-router-dom";
const onLinkClick = (href, navigate) => {
const origin = window.location.origin;
if (href.startsWith(origin)) {
// Strip origin, keep only path + query + hash
const relativePath = href.slice(origin.length) || "/";
navigate(relativePath);
} else {
window.location.href = href;
}
};
const handleTextClick = (e, navigate) => {
if (e.target.tagName === "A") {
e.preventDefault();
onLinkClick(e.target.href, navigate);
}
if (e.target.parentNode.tagName === "A") {
e.preventDefault();
onLinkClick(e.target.parentNode.href, navigate);
}
};
// Function to render different content types
const renderContentElement = (element, index, navigate) => {
const { type, text } = element;
switch (type) {
case "title1":
return (
<h1
key={index}
dangerouslySetInnerHTML={{ __html: text }}
className="th-title"
onClick={(e) => {
handleTextClick(e, navigate);
}}
/>
);
case "title2":
return (
<h2
key={index}
className="th-title"
dangerouslySetInnerHTML={{ __html: text }}
onClick={(e) => {
handleTextClick(e, navigate);
}}
/>
);
case "title3":
return (
<h3
key={index}
className="th-title"
dangerouslySetInnerHTML={{ __html: text }}
onClick={(e) => {
handleTextClick(e, navigate);
}}
/>
);
case "title4":
return (
<h4
key={index}
className="th-title"
dangerouslySetInnerHTML={{ __html: text }}
onClick={(e) => {
handleTextClick(e, navigate);
}}
/>
);
case "paragraph":
return (
<p
key={index}
className="th-paragraph"
dangerouslySetInnerHTML={{ __html: text }}
onClick={(e) => {
handleTextClick(e, navigate);
}}
/>
);
case "divider":
return <hr key={index} className="th-divider" />;
case "list": {
const { ordered, children = [] } = element;
const ListTag = ordered ? "ol" : "ul";
return (
<ListTag
key={index}
className={
ordered ? "th-list th-orderedList" : "th-list th-unorderedList"
}
>
{children.map((child, childIdx) =>
renderContentElement(child, childIdx),
)}
</ListTag>
);
}
case "listItem": {
// Support for nested lists inside list items
const { text, children = [] } = element;
return (
<li key={index} className="th-listItem">
{text && <span dangerouslySetInnerHTML={{ __html: text }} />}
{children.length > 0 &&
children.map((child, childIdx) =>
renderContentElement(child, childIdx),
)}
{/* 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="th-button"
dangerouslySetInnerHTML={{ __html: `${element?.icon || ""}${text}` }}
/>
);
case "properties":
return <PropertyCards properties={element.properties} />;
case "contactForm":
return <ContactForm />;
default:
return (
<p
key={index}
className="th-paragraph"
dangerouslySetInnerHTML={{ __html: text }}
onClick={(e) => {
handleTextClick(e, navigate);
}}
/>
);
}
};
const ContentRenderer = ({ content }) => {
const navigate = useNavigate();
// Handle both old format (string) and new format (array of objects)
const renderContent = () => {
if (!content) {
return (
<p className="th-content-default">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="th-content-default">{content}</p>;
}
// If content is an array (new format), render each element
if (Array.isArray(content)) {
return content.map((element, index) =>
renderContentElement(element, index, navigate),
);
}
return <p className="th-content-default">Invalid content format.</p>;
};
return renderContent();
};
export default ContentRenderer;

View File

@ -0,0 +1,49 @@
import PropTypes from "prop-types";
const FeaturesList = ({ features, maxVisible = 7 }) => {
if (!features || !Array.isArray(features)) {
return null;
}
// Simple heuristic: assume each feature takes about 60px (including padding/gap)
// 2 rows would be approximately 120px, so show counter if we have more than 3-4 features
const estimatedMaxVisible = maxVisible;
const showOverflowCounter = features.length > estimatedMaxVisible;
if (maxVisible == null) {
return (
<div className="th-features-list">
{features.map((feature, i) => (
<div key={i} className="th-features-listItem">
{feature}
</div>
))}
</div>
);
}
const visibleFeatures = showOverflowCounter
? features.slice(0, estimatedMaxVisible - 1) // Leave space for counter
: features;
return (
<div className="th-features-list">
{visibleFeatures.map((feature, i) => (
<div key={i} className="th-features-listItem">
{feature}
</div>
))}
{showOverflowCounter && (
<div className="th-features-listItem th-features-overflow-counter">
+{features.length - visibleFeatures.length}
</div>
)}
</div>
);
};
FeaturesList.propTypes = {
features: PropTypes.array,
maxVisible: PropTypes.number,
};
export default FeaturesList;

View File

@ -0,0 +1,53 @@
import PropTypes from "prop-types";
import { useState, useEffect } from "react";
import { useMediaQuery } from "react-responsive";
import { useSettingsContext } from "../contexts/SettingsContext";
const HeaderLogo = ({ large = false, visible = true }) => {
const [currentLogo, setCurrentLogo] = useState(null);
const isMobile = useMediaQuery({ maxWidth: 800 });
const settings = useSettingsContext();
useEffect(() => {
if (settings.branding.length > 0) {
const largeLogo = settings.branding.find(
(brandingItem) =>
brandingItem.type === "logo" && brandingItem.size === "large",
);
const smallLogo = settings.branding.find(
(brandingItem) =>
brandingItem.type === "logo" && brandingItem.size === "small",
);
if (large) {
setCurrentLogo(largeLogo.content);
} else {
setCurrentLogo(smallLogo.content);
}
}
}, [large, isMobile, settings.branding]);
return (
<header className="th-header-logo">
<div
dangerouslySetInnerHTML={{ __html: currentLogo }}
alt="The Hideout Logo"
className={`th-logo-image th-logo-current ${
large && !isMobile ? "th-logo-image-large" : ""
} ${large && isMobile ? "th-logo-image-large-mobile" : ""}
${!visible ? "th-logo-image-hidden" : ""}`}
/>
</header>
);
};
HeaderLogo.propTypes = {
currentTheme: PropTypes.string,
large: PropTypes.bool,
themes: PropTypes.shape({
find: PropTypes.func,
}),
visible: PropTypes.bool,
};
export default HeaderLogo;

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

@ -0,0 +1,106 @@
import React, { useState, useEffect, useRef } from "react";
import { decode } from "blurhash";
import { useImageContext } from "../contexts/ImageContext";
const Image = ({ src, alt, className, loading = "lazy", ...props }) => {
const { imageObjects } = useImageContext();
const [blurhashCanvas, setBlurhashCanvas] = useState(null);
const [imageLoaded, setImageLoaded] = useState(false);
const [currentImageObj, setCurrentImageObj] = useState(null);
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);
}
} else {
setCurrentImageObj(null);
processedSrcRef.current = null;
setImageLoaded(false);
}
}, [src, imageObjects]);
// 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);
} catch (error) {
console.error("Failed to decode blurhash:", error);
}
}
}, [currentImageObj?.blurHash, blurhashCanvas, src]);
// Handle image load
const handleImageLoad = () => {
setImageLoaded(true);
};
// Handle image error
const handleImageError = () => {
setImageLoaded(false);
};
if (currentImageObj) {
return (
<div className={`th-image-container ${className || ""}`}>
{blurhashCanvas ? (
<img
src={blurhashCanvas.toDataURL()}
alt={alt}
className="th-image-blurhash"
/>
) : null}
{currentImageObj.blob && (
<img
src={currentImageObj.blob}
alt={alt}
className={`th-image-content ${className}`}
loading={loading}
onLoad={handleImageLoad}
onError={handleImageError}
style={{
opacity: imageLoaded ? 1 : 0,
...props.style,
}}
{...props}
/>
)}
</div>
);
}
// Show error state
if (currentImageObj && currentImageObj.loadingState === "error") {
return <div className="th-image-error">Failed to load image</div>;
}
// Fallback loading state
if (currentImageObj && currentImageObj.loadingState === "loading") {
return <div className="th-image-loading">Loading image...</div>;
}
// No src provided or no matching image object
return null;
};
export default Image;

View File

@ -0,0 +1,58 @@
import PropTypes from "prop-types";
import { useState, useEffect } from "react";
import Image from "./Image";
const ImageCarousel = ({ page, interval = 8000, className }) => {
const images = page.images || [];
const [currentIndex, setCurrentIndex] = useState(0);
// Cycle through images
useEffect(() => {
if (images.length <= 1) return;
const timer = setInterval(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length);
}, interval);
return () => clearInterval(timer);
}, [images, interval]);
if (images.length === 0) {
return (
<div className="th-no-images">
<p>No images available</p>
</div>
);
}
return (
<div className={`th-image-carousel ${className}`}>
{images.map((src, index) => (
<div
key={`carousel-image-${index}-${page.slug}`}
className={`th-carousel-image-wrapper ${
index === currentIndex ? "th-active" : "th-hidden"
}`}
>
<Image
src={src}
alt={`${page.slug} image ${index + 1}`}
className="th-carousel-image"
loading="lazy"
/>
</div>
))}
</div>
);
};
ImageCarousel.propTypes = {
className: PropTypes.string,
interval: PropTypes.number,
page: PropTypes.shape({
images: PropTypes.array,
slug: PropTypes.any,
}),
};
export default ImageCarousel;

View File

@ -0,0 +1,67 @@
import PropTypes from "prop-types";
import Image from "./Image";
const ImageSidebar = ({ imageCollections, currentSlug }) => {
// Helper function to get the grid layout based on number of images
const getGridLayout = (count) => {
switch (count) {
case 1:
return "th-single-image-grid";
case 2:
return "th-two-images-grid";
case 3:
return "th-three-images-grid";
case 4:
return "th-four-images-grid";
default:
return "th-multiple-images-grid";
}
};
// Render a separate div for each page, controlling visibility with opacity
return (
<div className="th-images-sidebar">
{imageCollections.map((page) => {
const isCurrentPage = page.slug === currentSlug;
const pageImages = page.images || [];
const pageImageCount = pageImages.length;
return (
<div
key={`page-${page.slug}`}
className={`th-images-container ${getGridLayout(pageImageCount)} ${
isCurrentPage ? "" : "th-images-container-hidden"
}`}
>
{pageImages.length > 0 ? (
pageImages.map((image, index) => (
<div
key={`image-${index}-${page.slug}`}
className="th-image-wrapper"
>
<Image
src={image}
alt={`${page.slug} image ${index + 1}`}
className="th-sidebar-image"
loading="lazy"
/>
</div>
))
) : (
<div key={`no-images-${page.slug}`} className="th-no-images">
<p>No images available</p>
</div>
)}
</div>
);
})}
</div>
);
};
ImageSidebar.propTypes = {
currentSlug: PropTypes.string,
imageCollections: PropTypes.array.isRequired,
};
export default ImageSidebar;

View File

@ -0,0 +1,38 @@
import PropTypes from "prop-types";
import { useEffect, useState } from "react";
const LoadingModal = ({ visible = true }) => {
const [show, setShow] = useState(visible);
useEffect(() => {
let timeout;
if (visible) {
// show immediately
setShow(true);
} else {
// delay hiding by 150ms
timeout = setTimeout(() => setShow(false), 150);
}
return () => clearTimeout(timeout);
}, [visible]);
return (
<div
className={`th-loading-modal ${
show ? "th-loading-modal-visible" : "th-loading-modal-hidden"
}`}
>
<img
src={"/assets/logo-dark.svg"}
alt="The Hideout Logo"
className="th-loading-logo"
/>
</div>
);
};
LoadingModal.propTypes = {
visible: PropTypes.bool,
};
export default LoadingModal;

View File

@ -0,0 +1,32 @@
import { useState } from "react";
import { useMenu } from "../contexts/MenuContext";
import { Cross as Hamburger } from "hamburger-react";
import { useTheme } from "../contexts/ThemeContext";
import PropTypes from "prop-types";
const MenuButton = ({ isInverted = false }) => {
const { setMenuVisible, menuVisible } = useMenu();
const { theme } = useTheme();
return (
<button className="th-header-button th-header-button-menu">
<Hamburger
toggled={menuVisible}
toggle={setMenuVisible}
size={20}
rounded
color={
isInverted
? theme?.backgroundColor || "#ffffff"
: theme?.textColor || "#ffffff"
}
/>
</button>
);
};
MenuButton.propTypes = {
isInverted: PropTypes.bool,
};
export default MenuButton;

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

@ -0,0 +1,211 @@
import { useEffect, useRef, useState } from "react";
import PropTypes from "prop-types";
import { Layout } from "antd";
import ContentRenderer from "./ContentRenderer";
import HeaderLogo from "./HeaderLogo";
import ScrollIcon from "../icons/ScrollIcon";
import { useMediaQuery } from "react-responsive";
import ImageCarousel from "./ImageCarousel";
import CloseButton from "./CloseButton";
import MenuButton from "./MenuButton";
import { useSettingsContext } from "../contexts/SettingsContext";
const { Content } = Layout;
const Page = ({
pageData,
theme = null,
visible = true,
id = 0,
showClose = false,
isSubPage = false,
showMenu = true,
onClose = () => {},
}) => {
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
const mobileImage =
isMobile &&
!pageData.showProperties &&
!pageData.showContactForm &&
!pageData.hideMobileImage;
const contentRef = useRef(null);
const [isImageShrunk, setIsImageShrunk] = useState(false);
const [safariBlurToggle, setSafariBlurToggle] = useState(false);
const shrinkDistance = 1;
const settings = useSettingsContext();
const themes = settings?.themes || [];
if (theme == null) {
theme = settings?.globalThemes?.page || settings?.themes[0];
}
// Reset scroll position and image state when visible becomes false
useEffect(() => {
if (!visible && contentRef.current) {
contentRef.current.scrollTop = 0;
setIsImageShrunk(false);
}
}, [visible]);
// SafariBlurToggle animation when isImageShrunk changes
useEffect(() => {
if (!mobileImage) return;
let animationId;
let timeoutId;
const startTime = Date.now();
const duration = 750; // 0.75 seconds
const animate = () => {
const elapsed = Date.now() - startTime;
if (elapsed < duration) {
setSafariBlurToggle((prev) => !prev);
animationId = requestAnimationFrame(animate);
} else {
// Stop toggling after 0.75s
setSafariBlurToggle(false);
}
};
// Start the animation
animationId = requestAnimationFrame(animate);
return () => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [isImageShrunk, mobileImage]);
useEffect(() => {
if (!contentRef.current || !mobileImage) return;
const handleScroll = () => {
// Don't shrink image if page is not visible
if (!visible) return;
const el = contentRef.current;
const scrollTop = el.scrollTop;
const scrollHeight = el.scrollHeight;
const clientHeight = el.clientHeight;
// Check if the container can be scrolled (is overflowing)
const canScroll = scrollHeight > clientHeight;
const shouldShrink = scrollTop > shrinkDistance;
// If container can't be scrolled, keep image shrunk if it's already shrunk
if (!canScroll && isImageShrunk) {
return; // Keep the image shrunk
}
// If container can be scrolled, allow image to un-shrink when at top
if (canScroll) {
if (scrollTop <= shrinkDistance) {
setIsImageShrunk(false);
} else if (shouldShrink) {
setIsImageShrunk(true);
}
} else if (shouldShrink) {
// If container can't be scrolled but we're past threshold, shrink
setIsImageShrunk(true);
}
};
const el = contentRef.current;
el.addEventListener("scroll", handleScroll);
return () => {
el.removeEventListener("scroll", handleScroll);
};
}, [mobileImage, visible, isImageShrunk]);
return (
<div
className={
!isMobile
? "th-page-container"
: "th-page-container th-page-container-mobile"
}
style={{
"--th-backgroundColor": theme?.backgroundColor,
"--th-textColor": theme?.textColor,
background: "var(--th-backgroundColor)",
}}
>
<div
className={`th-header${visible == false ? " th-header-hidden" : ""}${
isLargeMobile ? " th-header-mobile" : ""
}${mobileImage ? " th-header-float" : ""}`}
style={{
"--th-textColor":
pageData?.invertHeader && isMobile
? theme?.backgroundColor
: theme?.textColor,
}}
>
<HeaderLogo
themes={themes}
currentTheme={pageData.theme}
large={id === 0 && !isSubPage}
/>
{showClose && <CloseButton onClick={onClose} />}
{showMenu && id != 0 && !isSubPage && (
<MenuButton isInverted={pageData.invertHeader && isMobile} />
)}
</div>
{mobileImage && (
<div
className={`th-page-mobile-image-wrapper${
visible == false ? ` th-page-mobile-image-wrapper-hidden` : ``
}${isImageShrunk ? ` th-page-mobile-image-wrapper-shrunk` : ``}`}
>
<div
className={`th-top-gradient ${safariBlurToggle ? "refresh" : ""}`}
></div>
<ImageCarousel page={pageData} className="th-mobile-image" />
</div>
)}
<Content
className={`th-page-content${
visible == false ? " th-page-content-hidden" : ""
}${isLargeMobile ? " th-page-content-mobile" : ""}`}
ref={contentRef}
>
<div className="th-content-container-wrapper">
<div
className={`th-content-container ${
mobileImage ? " th-content-container-mobile-image" : ""
}`}
>
<ContentRenderer content={pageData?.content} />
</div>
</div>
</Content>
{pageData?.showScroll == true && <ScrollIcon visible={visible} />}
</div>
);
};
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,
visible: PropTypes.bool,
onClose: PropTypes.func,
};
export default Page;

View File

@ -0,0 +1,53 @@
import PropTypes from "prop-types";
import ImageCarousel from "./ImageCarousel";
import FeaturesList from "./FeaturesList";
import { useNavigate } from "react-router-dom";
const PropertyCard = ({ propertyData, key }) => {
const navigate = useNavigate();
return (
<div
className="th-property-card-wrapper"
onClick={() => {
navigate(`/properties/${propertyData.slug}`);
}}
>
<div className="th-property-card" key={key}>
<div className="th-property-card-image-wrapper">
{propertyData?.images ? (
<ImageCarousel
page={propertyData}
className="th-property-card-image"
/>
) : (
<div className="th-property-card-placeholder-image">No Image</div>
)}
</div>
<div className="th-property-card-content">
<h3 className="th-property-card-title">{propertyData?.name}</h3>
<p className="th-property-card-address">
{propertyData?.address.split("\n").join(", ")}
</p>
<FeaturesList features={propertyData?.features} />
</div>
</div>
</div>
);
};
PropertyCard.propTypes = {
key: PropTypes.any,
propertyData: PropTypes.shape({
address: PropTypes.shape({
split: PropTypes.func,
}),
features: PropTypes.shape({
map: PropTypes.func,
}),
images: PropTypes.any,
name: PropTypes.any,
slug: PropTypes.any,
}),
};
export default PropertyCard;

View File

@ -0,0 +1,100 @@
import PropTypes from "prop-types";
import { useState, useEffect } from "react";
import PropertyCard from "./PropertyCard";
import RightIcon from "../icons/RightIcon";
import LeftIcon from "../icons/LeftIcon";
const PropertyCards = ({ properties }) => {
const [currentIndex, setCurrentIndex] = useState(0);
const scrollToIndex = (index) => {
const clampedIndex = Math.max(0, Math.min(properties.length - 1, index));
setCurrentIndex(clampedIndex);
const container = document.getElementById("property-cards-container");
const target = container?.children[clampedIndex];
if (container && target) {
container.scrollTo({
left: target.offsetLeft,
behavior: "smooth",
});
}
};
const scrollLeft = () => scrollToIndex(currentIndex - 1);
const scrollRight = () => scrollToIndex(currentIndex + 1);
// Scroll spy to track visible card
useEffect(() => {
const container = document.getElementById("property-cards-container");
if (!container) return;
let scrollTimeout;
const handleScroll = () => {
const containerCenter = container.scrollLeft + container.clientWidth / 2;
let closestIndex = 0;
let minDistance = Infinity;
Array.from(container.children).forEach((el, i) => {
const elCenter = el.offsetLeft + el.offsetWidth / 2;
const distance = Math.abs(containerCenter - elCenter);
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
});
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => setCurrentIndex(closestIndex), 50);
};
container.addEventListener("scroll", handleScroll, { passive: true });
return () => {
container.removeEventListener("scroll", handleScroll);
clearTimeout(scrollTimeout);
};
}, [properties]);
return (
<div className="th-property-cards-container">
{currentIndex != 0 && (
<>
<button className="th-scroll-btn th-left" onClick={scrollLeft}>
<LeftIcon />
</button>
</>
)}
<div
className={`th-property-cards-gradient th-property-cards-gradient-left ${
currentIndex == properties.length - 1 && properties.length > 1
? ""
: "th-hidden"
}`}
/>
<div className="th-property-cards" id="property-cards-container">
{properties.map((property, i) => (
<PropertyCard propertyData={property} key={i} />
))}
</div>
<div
className={`th-property-cards-gradient th-property-cards-gradient-right ${
!(currentIndex !== properties.length - 1) ? "th-hidden" : ""
}`}
/>
{currentIndex !== properties.length - 1 && (
<button className="th-scroll-btn th-right" onClick={scrollRight}>
<RightIcon />
</button>
)}
</div>
);
};
PropertyCards.propTypes = {
properties: PropTypes.array,
};
export default PropertyCards;

View File

@ -0,0 +1,134 @@
import PropTypes from "prop-types";
import { Layout } from "antd";
import ContentRenderer from "./ContentRenderer";
import HeaderLogo from "./HeaderLogo";
import FeaturesList from "./FeaturesList";
import { useMediaQuery } from "react-responsive";
import ImageCarousel from "./ImageCarousel";
import CloseButton from "./CloseButton";
import AirbnbLogo from "../icons/AirbnbLogo";
import BookingcomLogo from "../icons/BookingcomLogo";
import { useSettingsContext } from "../contexts/SettingsContext";
const { Content } = Layout;
const PropertyPage = ({ propertyData, visible = true, onClose = () => {} }) => {
const settings = useSettingsContext();
const themes = settings?.themes || [];
const theme = settings?.globalThemes?.property || settings?.themes[0];
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
const isMobile = useMediaQuery({ maxWidth: 800 });
console.log("Rerender " + propertyData.name, visible);
const mobileImage = isMobile;
return (
<div
className={
!isMobile
? "th-page-container th-property-page"
: "th-page-container th-page-container-mobile th-property-page"
}
style={{
"--th-backgroundColor": theme.backgroundColor,
"--th-textColor": theme.textColor,
background: "var(--th-backgroundColor)",
}}
>
<div
className={`th-header${visible == false ? " th-header-hidden" : ""}${
isLargeMobile ? " th-header-mobile" : ""
}${mobileImage ? " th-header-float" : ""}`}
>
<HeaderLogo themes={themes} currentTheme={theme.name} />
<CloseButton onClick={onClose} />
</div>
{mobileImage && (
<div className="th-page-mobile-image-wrapper">
<ImageCarousel page={propertyData} className="th-mobile-image" />
</div>
)}
<Content
className={`th-page-content${
visible == false ? " th-page-content-hidden" : ""
}${isLargeMobile ? " th-page-content-mobile" : ""}`}
style={{ color: "var(--th-textColor)" }}
>
<div className="th-content-container-wrapper">
<div className="th-content-container">
<h1 className="th-title">
{propertyData?.name ? propertyData.name.toLowerCase() : "n/a"}
</h1>
<hr className="th-divider" />
<p className="th-paragraph th-property-address">
{propertyData?.address.split("\n").join(", ").toLowerCase()}
</p>
<div className="th-property-specs">
<p>
price:{" "}
<span>
£{propertyData.minPrice} - £{propertyData.maxPrice}
</span>
</p>
<p>
max capacity: <span>{propertyData.maxOccupancy}</span>
</p>
<p>
bathrooms: <span>{propertyData.bathrooms}</span>
</p>
<p>
bedrooms: <span>{propertyData.bedrooms}</span>
</p>
</div>
<FeaturesList features={propertyData?.features} maxVisible={null} />
{propertyData?.content?.length > 0 && <hr className="th-divider" />}
{mobileImage && (
<div className="th-mobile-image-wrapper">
<ImageCarousel
page={propertyData}
className="th-mobile-image"
/>
</div>
)}
<ContentRenderer content={propertyData?.content} />
</div>
</div>
<div
className={
isMobile
? "th-mobile-booking-buttons th-booking-buttons"
: "th-booking-buttons"
}
>
<button
className="th-airbnb-button"
onClick={() => window.open(propertyData?.airbnbLink, "_blank")}
>
<AirbnbLogo />
Airbnb
</button>
<button
className="th-bookingcom-button"
onClick={() => window.open(propertyData?.bookingcomLink, "_blank")}
>
<BookingcomLogo />
Booking.com
</button>
</div>
</Content>
</div>
);
};
PropertyPage.propTypes = {
propertyData: PropTypes.object.isRequired,
theme: PropTypes.shape({
name: PropTypes.string,
backgroundColor: PropTypes.string,
textColor: PropTypes.string,
}),
themes: PropTypes.any,
visible: PropTypes.bool,
onClose: PropTypes.func,
};
export default PropertyPage;

View File

@ -0,0 +1,45 @@
import PropTypes from "prop-types";
import { motion, AnimatePresence } from "framer-motion";
import { useMediaQuery } from "react-responsive";
export default function SubPage({ visible, onClose, children }) {
const isMobile = useMediaQuery({ maxWidth: 800 });
return (
<AnimatePresence>
{visible && (
<motion.div
className={
isMobile ? "th-mobile-sub-page-wrapper" : "th-sub-page-wrapper"
}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Full height panel */}
<motion.div
initial={{ y: "100%" }}
animate={{ y: "-100%" }}
exit={{ y: "100%" }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
SubPage.propTypes = {
children: PropTypes.any,
onClose: PropTypes.func,
visible: PropTypes.bool,
};

View File

@ -0,0 +1,66 @@
import { useEffect, useRef, forwardRef, useImperativeHandle } from "react";
import PropTypes from "prop-types";
const Turnstile = forwardRef(
({ siteKey = null, onVerify, theme = "auto", size = "normal" }, ref) => {
const widgetRef = useRef(null);
const widgetIdRef = useRef(null);
// 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 || widgetIdRef.current !== null) return;
const renderWidget = () => {
if (!window.turnstile) return;
widgetIdRef.current = window.turnstile.render(widgetRef.current, {
sitekey: siteKey,
callback: (token) => {
if (typeof onVerify === "function") {
onVerify(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 () => {
if (window.turnstile && widgetIdRef.current !== null) {
window.turnstile.remove(widgetIdRef.current);
widgetIdRef.current = null;
}
};
}, [siteKey]);
return <div ref={widgetRef} data-theme={theme} 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;

View File

@ -0,0 +1,35 @@
import PropTypes from "prop-types";
import { useState } from "react";
import { Modal } from "antd";
import Turnstile from "react-turnstile";
const TurnstileModal = ({ open, onClose, onSuccess }) => {
const [turnstileToken, setTurnstileToken] = useState("");
const handleVerify = (token) => {
setTurnstileToken(token);
onClose(); // Close modal after verification
onSuccess(turnstileToken); // Notify parent component
};
return (
<Modal
open={open}
footer={null}
onCancel={onClose}
closeIcon={null}
centered
className="tbturnstile"
>
<Turnstile sitekey="0x4AAAAAAA_bc3QTrE68whtg" onVerify={handleVerify} />
</Modal>
);
};
TurnstileModal.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onSuccess: PropTypes.func.isRequired,
};
export default TurnstileModal;

View File

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

View File

@ -0,0 +1,334 @@
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, blobUrl) {
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, blobUrl);
this.cacheOrder.push(key);
this.evictIfNeeded();
}
evictIfNeeded() {
while (this.cache.size > this.maxCacheSize) {
const oldestKey = this.cacheOrder.shift();
if (oldestKey && this.cache.has(oldestKey)) {
const blobUrl = this.cache.get(oldestKey);
if (blobUrl && blobUrl.startsWith("blob:")) {
URL.revokeObjectURL(blobUrl);
}
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 _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 blobUrl = URL.createObjectURL(blob);
this.set(primaryUrl, blobUrl); // Cache using primary URL as key
console.log(
`[ImageCache] Successfully loaded from mirror URL: ${fallbackUrl}`,
);
return blobUrl;
} 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 blobUrl = URL.createObjectURL(blob);
this.set(primaryUrl, blobUrl);
console.log(
`[ImageCache] Successfully loaded from primary URL: ${primaryUrl}`,
);
return blobUrl;
} 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 blobUrl = URL.createObjectURL(blob);
this.set(url, blobUrl);
return blobUrl;
} 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() {
for (const blobUrl of this.cache.values()) {
if (blobUrl && blobUrl.startsWith("blob:")) {
URL.revokeObjectURL(blobUrl);
}
}
this.cache.clear();
this.cacheOrder = [];
this.loadingPromises.clear();
}
remove(url) {
const key = this.getCacheKey(url);
if (this.cache.has(key)) {
const blobUrl = this.cache.get(key);
if (blobUrl && blobUrl.startsWith("blob:")) {
URL.revokeObjectURL(blobUrl);
}
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(async (images) => {
// images: [{ blurHash, url, mirrorUrl? }]
if (!images || images.length === 0) {
setImageObjects([]);
setAllImagesLoaded(true);
return;
}
setAllImagesLoaded(false);
// Initialize image objects with loading state
const initialImageObjects = images.map((img) => ({
src: img.url,
blurHash: img.blurHash,
mirrorUrl: img.mirrorUrl,
blob: null,
loadingState: "loading",
}));
setImageObjects(initialImageObjects);
// Load images individually and update state as each completes
const loadPromises = images.map(async (img) => {
try {
const blobUrl = await imageCache.loadImage(img.url, img.mirrorUrl);
console.log(`Image loaded: ${img.url}`);
// Update the specific image object as it loads
setImageObjects((prev) =>
prev.map((imgObj) =>
imgObj.src === img.url
? { ...imgObj, blob: blobUrl, loadingState: "loaded" }
: imgObj,
),
);
return { url: img.url, blobUrl, success: true };
} catch (error) {
console.error(`Failed to load image: ${img.url}`, error);
// Update the specific image object with error state
setImageObjects((prev) =>
prev.map((imgObj) =>
imgObj.src === img.url
? { ...imgObj, loadingState: "error" }
: imgObj,
),
);
return { url: img.url, blobUrl: null, success: false, error };
}
});
// Wait for all images to complete (but state updates happen individually)
await Promise.allSettled(loadPromises);
console.log("All images loading completed");
setAllImagesLoaded(true);
}, []);
const loadIndividualImage = useCallback(
async (url) => {
console.log(`[ImageProvider] loadIndividualImage called for: ${url}`);
const imageObj = imageObjects.find((img) => img.src === url);
// 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 blobUrl = imageCache.get(url);
setImageObjects((prev) =>
prev.map((img) =>
img.src === url
? { ...img, blob: blobUrl, 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 blobUrl = await imageCache.loadImage(url, mirrorUrl);
setImageObjects((prev) =>
prev.map((img) =>
img.src === url
? { ...img, blob: blobUrl, loadingState: "loaded" }
: img,
),
);
} catch (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>
);
};
export default ImageContext;

View File

@ -0,0 +1,75 @@
import { createContext, useContext, useState } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useMediaQuery } from "react-responsive";
import MenuButton from "../components/MenuButton";
// Create the context
const MenuContext = createContext();
export const useMenu = () => useContext(MenuContext);
export const MenuProvider = ({ children, pages, currentPageSlug }) => {
const [menuVisible, setMenuVisible] = useState(false);
const isMobile = useMediaQuery({ maxWidth: 800 });
const navigate = useNavigate();
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
return (
<MenuContext.Provider value={{ menuVisible, setMenuVisible }}>
{children}
{/* Fullscreen menu overlay */}
<AnimatePresence>
{menuVisible && (
<motion.div
className={`th-menu-page${isMobile ? " th-mobile-menu-page" : ""}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div
className={`th-header${isLargeMobile ? " th-header-mobile" : ""} th-menu-header`}
>
<MenuButton />
</div>
<div
className={`th-menu-page-container${
isMobile ? " th-mobile-menu-page-container" : ""
}`}
>
<ul className="th-menu-nav">
{pages
.filter((page) => page.pageType === "landingPage")
.map((page) => (
<li key={page.slug}>
<button
onClick={() => {
navigate(page.slug);
setMenuVisible(false);
}}
className={`th-menu-nav-item${page.slug === currentPageSlug ? " th-menu-nav-item-active" : ""}`}
>
{page.name || page.slug}
</button>
</li>
))}
</ul>
</div>
</motion.div>
)}
</AnimatePresence>
</MenuContext.Provider>
);
};
MenuProvider.propTypes = {
children: PropTypes.node.isRequired,
currentPageSlug: PropTypes.string.isRequired,
pages: PropTypes.arrayOf(
PropTypes.shape({
slug: PropTypes.string.isRequired,
label: PropTypes.string, // optional display label
}),
).isRequired,
};

View File

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

View File

@ -0,0 +1,53 @@
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.classList.add("th-body-bg");
document.body.style.backgroundColor = theme.backgroundColor || "";
// Set global CSS vars
document.documentElement.style.setProperty(
"--th-textColor",
theme.textColor || "#000000",
);
document.documentElement.style.setProperty(
"--th-backgroundColor",
theme.backgroundColor || "#ffffff",
);
}
return () => {
document.body.classList.remove("th-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);

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

23
src/icons/ScrollIcon.jsx Normal file
View File

@ -0,0 +1,23 @@
import { useMediaQuery } from "react-responsive";
import ScrollIconSvg from "../../assets/scrollicon.svg?react";
import PropTypes from "prop-types";
const ScrollIcon = ({ visible = true }) => {
const isLargeMobile = useMediaQuery({ maxWidth: 1200 });
return (
<div
className={`th-scroll-icon-wrapper ${
isLargeMobile ? "th-scroll-icon-wrapper-mobile" : ""
} ${visible == false ? "th-hidden" : ""}`}
>
<ScrollIconSvg />
<p className="th-scroll-icon-text">SCROLL</p>
</div>
);
};
ScrollIcon.propTypes = {
visible: PropTypes.bool,
};
export default ScrollIcon;

13
src/main.jsx Normal file
View File

@ -0,0 +1,13 @@
import { BrowserRouter } from "react-router-dom";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "antd/dist/reset.css"; // Import Ant Design styles
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);

View File

@ -0,0 +1,64 @@
import { useState, useEffect } from "react";
import { useMediaQuery } from "react-responsive";
import { Card, Flex, message } from "antd";
import { LoadingOutlined } from "@ant-design/icons";
const CacheReloadView = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const isMobile = useMediaQuery({ maxWidth: 600 });
// Fetch blogs from API
useEffect(() => {
const reloadCache = async () => {
try {
const response = await fetch(
"https://web.tombutcher.work/api/utils/cache",
{ method: "POST" }
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
window.close();
setLoading(false);
} catch (err) {
console.error("Error reloading cache:", err);
setError(err.message);
setLoading(false);
message.error("Failed to reload cache!");
}
};
reloadCache();
}, []);
return (
<div className="tbview">
<Card
className={`tbcard ${isMobile ? "tbcard-mobile" : "tbcard-desktop"}`}
title="Reload Cache"
>
{loading ? (
<Flex
justify="center"
align="center"
className="tb-loading-container"
>
<LoadingOutlined className="tb-loading-icon" spin />
</Flex>
) : error ? (
<Flex justify="center" align="center">
<p>Error to reload cache: {error}</p>
</Flex>
) : (
<Flex justify="center" align="center">
<p>Cache Reloaded!</p>
</Flex>
)}
</Card>
</div>
);
};
export default CacheReloadView;

14
vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import svgo from "vite-plugin-svgo";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), svgo(), svgr()],
server: {
host: "0.0.0.0",
allowedHosts: ["thehideout.tombutcher.work"],
},
base: "/",
});

4
wrangler.jsonc Normal file
View File

@ -0,0 +1,4 @@
{
"name": "thehideout-ui",
"pages_build_output_dir": "./dist",
}

11480
yarn.lock Normal file

File diff suppressed because it is too large Load Diff