diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-22 15:08:37 +0300 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2025-07-22 15:08:37 +0300 |
| commit | fcc2f4704e39b0e69b377cc138f75027721dac22 (patch) | |
| tree | 732fc94b354a26c08fba9cc9059f9c6c900182be /src | |
Initial template
Diffstat (limited to 'src')
98 files changed, 5078 insertions, 0 deletions
diff --git a/src/assets/favicons/apple-touch-icon.png b/src/assets/favicons/apple-touch-icon.png Binary files differnew file mode 100644 index 0000000..b5cdb19 --- /dev/null +++ b/src/assets/favicons/apple-touch-icon.png diff --git a/src/assets/favicons/favicon.ico b/src/assets/favicons/favicon.ico Binary files differnew file mode 100644 index 0000000..cc0080d --- /dev/null +++ b/src/assets/favicons/favicon.ico diff --git a/src/assets/favicons/favicon.png b/src/assets/favicons/favicon.png Binary files differnew file mode 100644 index 0000000..f7cf5c7 --- /dev/null +++ b/src/assets/favicons/favicon.png diff --git a/src/assets/favicons/favicon.svg b/src/assets/favicons/favicon.svg new file mode 100644 index 0000000..8fcd56b --- /dev/null +++ b/src/assets/favicons/favicon.svg @@ -0,0 +1,20 @@ +<?xml version="1.0" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" + "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" + width="64.000000pt" height="64.000000pt" viewBox="0 0 64.000000 64.000000" + preserveAspectRatio="xMidYMid meet"> +<metadata> +Created by potrace 1.16, written by Peter Selinger 2001-2019 +</metadata> +<g transform="translate(0.000000,64.000000) scale(0.100000,-0.100000)" +fill="#000000" stroke="none"> +<path d="M210 425 l-105 -105 108 -108 107 -107 107 107 108 108 -105 105 +c-58 58 -107 105 -110 105 -3 0 -52 -47 -110 -105z m205 -15 l90 -90 -93 -93 +-92 -92 -92 92 -93 93 90 90 c49 49 92 90 95 90 3 0 46 -41 95 -90z"/> +<path d="M288 405 c-38 -21 -53 -73 -34 -118 16 -41 41 -57 86 -57 52 0 70 16 +70 60 0 39 -1 40 -35 40 -19 0 -35 -4 -35 -10 0 -5 9 -10 21 -10 15 0 20 -5 +17 -22 -2 -17 -11 -24 -31 -26 -64 -7 -94 80 -41 115 22 14 31 15 57 5 39 -15 +57 0 26 22 -29 20 -65 20 -101 1z"/> +</g> +</svg> diff --git a/src/assets/images/app-store.png b/src/assets/images/app-store.png Binary files differnew file mode 100644 index 0000000..8d634c0 --- /dev/null +++ b/src/assets/images/app-store.png diff --git a/src/assets/images/ceramic-coating.svg b/src/assets/images/ceramic-coating.svg new file mode 100644 index 0000000..ecf7c18 --- /dev/null +++ b/src/assets/images/ceramic-coating.svg @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <rect id="Artboard1" x="0" y="0" width="512" height="512" style="fill:none;"></rect> + <g id="Artboard11" serif:id="Artboard1"> + <g transform="matrix(1.51494,0,0,1.51494,-124.123,-144.979)"> + <path d="M393.677,270.342C393.651,277.003 393.507,288.575 392.994,306.59C392.859,311.324 396.594,315.277 401.328,315.411C406.062,315.546 410.015,311.812 410.15,307.078C411.09,274.016 410.897,263.645 410.781,261.878C410.344,255.216 405.145,254.093 404.805,253.984C403.819,253.668 401.165,253.274 397.362,253.203C387.171,253.014 364.88,253.893 354.501,253.893C349.764,253.893 345.919,257.738 345.919,262.474C345.919,267.21 349.764,271.055 354.501,271.055C363.556,271.055 381.706,270.382 392.909,270.34L393.677,270.342Z" style="fill:rgb(34,46,107);"></path> + <path d="M89.611,269.427C129.841,309.743 242.615,420.124 242.615,420.124C245.956,423.394 251.298,423.388 254.632,420.113C254.632,420.113 391.684,285.46 407.991,268.951C411.319,265.581 411.286,260.144 407.916,256.815C404.547,253.487 399.109,253.521 395.781,256.89C381.545,271.302 275.182,375.856 248.607,401.971C224.749,378.604 136.375,291.994 101.76,257.304C98.415,253.952 92.977,253.946 89.624,257.291C86.272,260.637 86.266,266.074 89.611,269.427Z" style="fill:rgb(34,46,107);"></path> + <g transform="matrix(1,0,0,1,11.1383,49.7293)"> + <path d="M177.856,206.946C175.648,208.497 174.205,211.065 174.205,213.966C174.205,218.702 178.05,222.547 182.786,222.547C183.62,222.547 185.266,222.508 187.302,221.277C187.744,221.009 189.451,219.664 192.041,217.196C210.261,199.83 297.676,111.571 327.115,82.934C330.51,79.631 330.585,74.194 327.283,70.799C323.98,67.405 318.543,67.329 315.148,70.632C285.736,99.243 198.403,187.423 180.2,204.772C179.24,205.687 178.23,206.608 177.856,206.946Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(1,0,0,1,46.489,72.5311)"> + <path d="M255.715,129.66C223.091,161.396 204.187,182.809 177.312,207.53C173.826,210.736 173.599,216.169 176.806,219.655C180.012,223.141 185.445,223.368 188.931,220.161C215.923,195.333 234.917,173.835 267.682,141.962C271.077,138.659 271.152,133.222 267.85,129.827C264.547,126.432 259.11,126.357 255.715,129.66Z" style="fill:rgb(34,46,107);"></path> + </g> + <path d="M136.874,423.566C198.039,423.566 359.31,423.297 375.075,423.297C379.811,423.297 383.656,419.452 383.656,414.715C383.656,409.979 379.811,406.134 375.075,406.134C359.31,406.134 198.039,406.403 136.874,406.403C132.138,406.403 128.293,410.248 128.293,414.985C128.293,419.721 132.138,423.566 136.874,423.566Z" style="fill:rgb(34,46,107);"></path> + <path d="M178.101,206.78C175.755,208.311 174.205,210.959 174.205,213.966C174.205,218.702 178.05,222.547 182.786,222.547C183.641,222.547 184.961,222.455 186.576,221.675C187.027,221.458 188.591,220.475 190.806,218.448C202.635,207.624 258.095,152.076 287.577,123.396C290.972,120.094 291.047,114.657 287.745,111.262C284.442,107.867 279.005,107.792 275.61,111.094C246.242,139.663 191.004,195.004 179.221,205.786C178.82,206.153 178.357,206.558 178.101,206.78Z" style="fill:rgb(34,46,107);"></path> + </g> + </g> +</svg> diff --git a/src/assets/images/color-change.svg b/src/assets/images/color-change.svg new file mode 100644 index 0000000..9f9d7ad --- /dev/null +++ b/src/assets/images/color-change.svg @@ -0,0 +1,44 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"> + <g id="Artboard1" transform="matrix(0.64,0,0,0.853333,0,0)"> + <rect x="0" y="0" width="800" height="600" style="fill:none;"></rect> + <g transform="matrix(1.5625,0,0,1.17187,0,0)"> + <path d="M245.987,91.456C245.987,91.456 254.307,414.012 236.563,427.968C228.224,434.526 109.244,431.278 109.244,431.278L109.485,481.898L36.378,481.787L29.22,291.14L60.188,219.288L89.868,115.968L245.987,91.456Z" style="fill:rgb(223,225,255);"></path> + </g> + <g transform="matrix(1.5625,0,0,1.85219,0,-140.949)"> + <path d="M105.058,249.826L123.77,168.008L245.772,166.831L246.724,247.86L105.058,249.826Z" style="fill:white;"></path> + </g> + <g> + <g transform="matrix(2.19821,0,0,1.64865,-481.273,-153.382)"> + <path d="M297.409,419.448C317.041,418.871 343.045,420.914 364.476,416.046C376.846,413.237 387.79,408.184 395.936,399.88C404.26,391.393 409.901,379.484 410.675,362.483C412.203,328.943 410.814,142.009 410.814,117.894L390.912,117.894C390.912,141.919 392.315,328.161 390.793,361.577C390.292,372.573 387.111,380.454 381.727,385.943C376.164,391.614 368.516,394.719 360.068,396.638C339.863,401.227 315.333,399.01 296.825,399.554L297.409,419.448Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(2.19821,0,0,1.64865,-481.273,-153.382)"> + <path d="M251.71,292.838C267.224,270.663 263.61,264.745 267.242,248.426C271.084,231.158 275.885,211.804 280.659,197.393C283.313,189.381 285.574,183.031 287.951,180.415C288.715,179.574 290.176,179.44 291.835,179.058C295.366,178.243 299.707,177.864 304.553,177.658C321.37,176.944 343.5,178.409 361.749,176.404C373.659,175.095 395.162,162.319 404.66,145.224C412.338,131.402 410.77,136.839 410.861,103.714L390.958,103.659C390.889,129.004 393.137,124.983 387.261,135.558C381.033,146.77 367.386,155.762 359.575,156.62C338.535,158.932 312.274,156.526 295.222,158.385C284.834,159.517 277.185,162.668 273.22,167.031C270.513,170.012 267.322,175.905 264.324,183.867C258.567,199.154 252.488,223.102 247.814,244.103C244.778,257.746 248.373,262.89 235.402,281.429L251.71,292.838Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(2.19821,0,0,1.64865,-481.273,-153.382)"> + <path d="M295.723,434.476C281.11,434.492 258.339,434.327 245.48,434.222C244.995,404.837 243.569,317.851 243.412,301.989C244.121,301.137 246.64,298.135 248.249,296.484C251.922,292.714 254.884,288.832 255.905,285.658L236.959,279.561C237.014,279.392 237.167,279.042 237.167,279.042C237.167,279.042 232.554,283.932 230.068,286.735C225.933,291.397 223.462,296.549 223.498,301.146C223.619,316.476 225.74,444.255 225.74,444.255C225.83,449.649 230.202,453.99 235.597,454.04C235.597,454.04 275.911,454.416 296.992,454.378C301.837,454.369 305.396,453.116 308.143,451.144C311.104,449.018 313.399,445.921 314.693,441.222C316.533,434.536 315.767,421.877 315.55,400.309L295.648,400.509C295.717,407.267 295.875,413.095 295.992,418.095C296.125,423.731 296.214,428.247 296.02,431.808C295.972,432.684 295.848,433.666 295.723,434.476Z" style="fill:rgb(34,46,107);"></path> + </g> + </g> + <g transform="matrix(2.19821,0,0,1.64865,-481.273,-153.382)"> + <path d="M504.184,398.393C481.2,398.393 411.117,398.497 405.824,398.497L405.824,418.399C411.117,418.399 481.2,418.296 504.184,418.296L504.184,398.393Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(-2.19821,0,0,1.64865,1281.27,-150.272)"> + <path d="M235.402,281.429C248.373,262.89 244.778,257.746 247.814,244.103C252.488,223.102 258.567,199.154 264.324,183.867C267.322,175.905 270.513,170.012 273.22,167.031C277.102,162.759 284.94,159.396 296.008,158.056C314.832,155.777 345.124,157.812 373.854,157.569L374.022,177.471C349.907,177.675 324.668,176.177 306.341,177.155C300.889,177.446 296.118,177.943 292.302,178.858C290.396,179.316 288.774,179.509 287.951,180.415C285.574,183.031 283.313,189.381 280.659,197.393C275.885,211.804 271.084,231.158 267.242,248.426C263.61,264.745 267.224,270.663 251.71,292.838L235.402,281.429Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(-2.19821,0,0,1.64865,1281.27,-150.272)"> + <path d="M243.413,302.071C243.574,318.153 244.996,404.888 245.48,434.222C258.356,434.328 281.17,434.493 295.78,434.476C296.087,432.968 296.562,430.351 296.701,428.04C297.138,420.786 296.953,410.774 297.179,397.547L317.079,397.887C316.713,419.292 317.06,432.88 314.95,440.341C313.626,445.023 311.44,448.246 308.722,450.475C305.768,452.896 302.02,454.369 296.992,454.378C275.911,454.416 235.597,454.04 235.597,454.04C230.202,453.99 225.83,449.649 225.74,444.255C225.74,444.255 223.619,316.476 223.498,301.146C223.464,296.818 226.161,291.134 230.728,285.882C233.79,282.362 239.252,276.344 239.252,276.344C239.252,276.344 239.075,276.748 239.015,276.934L257.961,283.031C256.843,286.503 252.335,291.797 247.609,296.883C246.108,298.497 244.051,301.217 243.413,302.071Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(-2.19821,0,0,1.65196,1282.08,-154.551)"> + <path d="M373.2,215.331C361.526,215.331 329.372,215.128 314.514,215.03C314.514,215.03 300.345,268.397 300.345,268.397C321.044,268.208 360.305,267.864 372.641,267.864L372.641,287.727C355.651,287.727 287.477,288.381 287.477,288.381C284.371,288.411 281.43,286.992 279.524,284.545C277.618,282.097 276.966,278.903 277.762,275.906L297.25,202.504C298.413,198.124 302.399,195.085 306.938,195.116C306.938,195.116 357.837,195.468 373.2,195.468L373.2,215.331Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(2.19821,0,0,1.65196,-481.273,-154.315)"> + <path d="M373.2,195.468C357.837,195.468 306.938,195.116 306.938,195.116C302.399,195.085 298.413,198.124 297.25,202.504L277.762,275.906C276.966,278.903 277.618,282.097 279.524,284.545C281.43,286.992 284.371,288.411 287.477,288.381C287.477,288.381 355.651,287.727 372.641,287.727L372.641,267.864C360.305,267.864 321.044,268.208 300.345,268.397C300.345,268.397 314.514,215.03 314.514,215.03C329.372,215.128 361.526,215.331 373.2,215.331L373.2,195.468Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(1.8074,0,0,1.35555,65.5237,-65.1496)"> + <circle cx="304.294" cy="348.627" r="32.035" style="fill:none;stroke:rgb(34,46,107);stroke-width:24.21px;"></circle> + </g> + <g transform="matrix(1.8074,0,0,1.35555,-365.442,-66.1442)"> + <circle cx="304.294" cy="348.627" r="32.035" style="fill:white;stroke:rgb(34,46,107);stroke-width:24.21px;"></circle> + </g> + </g> +</svg> diff --git a/src/assets/images/content-image-new.webp b/src/assets/images/content-image-new.webp Binary files differnew file mode 100644 index 0000000..899afc1 --- /dev/null +++ b/src/assets/images/content-image-new.webp diff --git a/src/assets/images/content-image.webp b/src/assets/images/content-image.webp Binary files differnew file mode 100644 index 0000000..3c2c1f5 --- /dev/null +++ b/src/assets/images/content-image.webp diff --git a/src/assets/images/content-image2-new.webp b/src/assets/images/content-image2-new.webp Binary files differnew file mode 100644 index 0000000..f0ff71e --- /dev/null +++ b/src/assets/images/content-image2-new.webp diff --git a/src/assets/images/content-image2.webp b/src/assets/images/content-image2.webp Binary files differnew file mode 100644 index 0000000..498d480 --- /dev/null +++ b/src/assets/images/content-image2.webp diff --git a/src/assets/images/customworks-hero.webp b/src/assets/images/customworks-hero.webp Binary files differnew file mode 100644 index 0000000..f2c54f1 --- /dev/null +++ b/src/assets/images/customworks-hero.webp diff --git a/src/assets/images/customworks-logo.png b/src/assets/images/customworks-logo.png Binary files differnew file mode 100644 index 0000000..5305ce7 --- /dev/null +++ b/src/assets/images/customworks-logo.png diff --git a/src/assets/images/engine-cleaning.svg b/src/assets/images/engine-cleaning.svg new file mode 100644 index 0000000..64691ca --- /dev/null +++ b/src/assets/images/engine-cleaning.svg @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" height="512px" viewBox="0 0 512 512" width="512px"><g><path d="m127.75 312.722656c-39.441406 0-71.527344 32.085938-71.527344 71.527344s32.085938 71.527344 71.527344 71.527344 71.527344-32.085938 71.527344-71.527344-32.085938-71.527344-71.527344-71.527344zm0 113.113282c-22.929688 0-41.585938-18.65625-41.585938-41.585938 0-22.933594 18.65625-41.585938 41.585938-41.585938 22.933594 0 41.585938 18.652344 41.585938 41.585938 0 22.929688-18.652344 41.585938-41.585938 41.585938zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m297.4375 164.125c29.246094 0 53.039062-23.792969 53.039062-53.039062 0-29.246094-23.792968-53.039063-53.039062-53.039063s-53.039062 23.792969-53.039062 53.039063c0 29.246093 23.792968 53.039062 53.039062 53.039062zm0-76.136719c12.734375 0 23.097656 10.363281 23.097656 23.097657 0 12.738281-10.363281 23.097656-23.097656 23.097656-12.738281 0-23.097656-10.359375-23.097656-23.097656 0-12.734376 10.359375-23.097657 23.097656-23.097657zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m511 354.808594c0-10.804688-2.796875-20.964844-7.695312-29.804688l-99.703126-246.644531c-14.003906-45.328125-56.296874-78.359375-106.164062-78.359375-38.425781 0-72.351562 19.613281-92.304688 49.347656l-.035156-.023437-181.484375 261.007812c-.082031.113281-.15625.226563-.238281.339844l-2.523438 3.628906.039063.027344c-13.199219 20.101563-20.890625 44.128906-20.890625 69.921875 0 70.441406 57.308594 127.75 127.75 127.75 13.707031 0 26.914062-2.179688 39.304688-6.195312l.089843.292968 299.148438-91.980468c.769531-.222657 1.535156-.457032 2.289062-.703126l.421875-.132812-.003906-.011719c24.386719-8.242187 42-31.328125 42-58.460937zm-102.925781 45.886718-173.34375 53.296876c13.125-20.066407 20.769531-44.027344 20.769531-69.742188 0-70.441406-57.308594-127.75-127.75-127.75-11.519531 0-22.683594 1.539062-33.308594 4.414062l93.0625-133.839843c7.773438 53.703125 54.105469 95.097656 109.933594 95.097656 46.980469 0 87.246094-29.320313 103.453125-70.621094l57.492187 142.21875c-2.96875-.4375-6.003906-.675781-9.09375-.675781-34.027343 0-61.714843 27.683594-61.714843 61.714844.003906 18.199218 7.925781 34.578125 20.5 45.886718zm-110.636719-370.753906c44.742188 0 81.144531 36.402344 81.144531 81.144532 0 44.742187-36.402343 81.144531-81.144531 81.144531s-81.144531-36.402344-81.144531-81.144531c0-44.742188 36.402343-81.144532 81.144531-81.144532zm-267.496094 354.308594c0-20.085938 6.089844-38.773438 16.511719-54.324219l1.875-2.691406c17.769531-24.6875 46.75-40.792969 79.421875-40.792969 53.933594 0 97.808594 43.875 97.808594 97.808594 0 53.929688-43.875 97.808594-97.808594 97.808594-53.929688 0-97.808594-43.878906-97.808594-97.808594zm429.675782.59375-2.117188.652344c-2.621094.703125-5.371094 1.082031-8.210938 1.082031-17.519531 0-31.769531-14.253906-31.769531-31.773437 0-17.515626 14.25-31.769532 31.769531-31.769532 17.519532 0 31.769532 14.253906 31.769532 31.773438 0 13.902344-8.980469 25.738281-21.441406 30.035156zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/></g> </svg> diff --git a/src/assets/images/google-play.png b/src/assets/images/google-play.png Binary files differnew file mode 100644 index 0000000..179f1ff --- /dev/null +++ b/src/assets/images/google-play.png diff --git a/src/assets/images/hero-image.jpg b/src/assets/images/hero-image.jpg Binary files differnew file mode 100644 index 0000000..9aee81c --- /dev/null +++ b/src/assets/images/hero-image.jpg diff --git a/src/assets/images/interior-detailing.svg b/src/assets/images/interior-detailing.svg new file mode 100644 index 0000000..fcf50a2 --- /dev/null +++ b/src/assets/images/interior-detailing.svg @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"> + <rect id="Artboard1" x="0" y="0" width="512" height="512" style="fill:none;"></rect> + <g id="Artboard11" serif:id="Artboard1"> + <g transform="matrix(1.13429,0,0,1.13429,-59.1814,-29.154)"> + <g transform="matrix(1,0,0,1,34.3005,-173.54)"> + <path d="M96.401,282.024C92.318,284.893 90.326,289.995 91.491,294.944C92.152,297.752 93.744,300.15 95.9,301.835C97.112,303.244 104.625,312.076 110.742,321.061C118.78,332.869 126.685,347.139 127.645,348.672C127.839,348.982 143.393,360.014 149.53,346.845C150.846,344.022 157.59,329.043 164.665,317.255C170.219,308.001 176.358,301.47 177.409,300.175C180.954,295.806 180.359,292.005 180.307,291.276C179.984,286.736 177.758,283.807 174.801,281.852C174.068,281.367 165.353,275.232 159.519,265.769C152.099,253.733 148.152,237.601 147.429,235.705C147.028,234.656 129.217,221.311 124.037,236.585C123.163,239.16 118.769,253.368 111.533,265.176C106.401,273.55 96.882,281.62 96.401,282.024ZM135.382,273.164C136.36,275.065 137.403,276.933 138.507,278.724C142.231,284.764 146.762,289.875 150.8,293.788C148.449,296.919 145.853,300.629 143.499,304.552C141.358,308.12 139.13,312.198 137.035,316.22C135.147,313.203 133.15,310.113 131.147,307.17C127.521,301.843 123.848,296.963 120.681,292.996C124.699,288.907 129.082,283.782 132.58,278.074C133.545,276.5 134.482,274.851 135.382,273.164Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(1,0,0,1,148.078,-76.7516)"> + <path d="M96.401,282.024C92.318,284.893 90.326,289.995 91.491,294.944C92.152,297.752 93.744,300.15 95.9,301.835C97.112,303.244 104.625,312.076 110.742,321.061C118.78,332.869 126.685,347.139 127.645,348.672C127.839,348.982 143.393,360.014 149.53,346.845C150.846,344.022 157.59,329.043 164.665,317.255C170.219,308.001 176.358,301.47 177.409,300.175C180.954,295.806 180.359,292.005 180.307,291.276C179.984,286.736 177.758,283.807 174.801,281.852C174.068,281.367 165.353,275.232 159.519,265.769C152.099,253.733 148.152,237.601 147.429,235.705C147.028,234.656 129.217,221.311 124.037,236.585C123.163,239.16 118.769,253.368 111.533,265.176C106.401,273.55 96.882,281.62 96.401,282.024ZM135.382,273.164C136.36,275.065 137.403,276.933 138.507,278.724C142.231,284.764 146.762,289.875 150.8,293.788C148.449,296.919 145.853,300.629 143.499,304.552C141.358,308.12 139.13,312.198 137.035,316.22C135.147,313.203 133.15,310.113 131.147,307.17C127.521,301.843 123.848,296.963 120.681,292.996C124.699,288.907 129.082,283.782 132.58,278.074C133.545,276.5 134.482,274.851 135.382,273.164Z" style="fill:rgb(34,46,107);"></path> + </g> + <path d="M96.401,282.024C92.318,284.893 90.326,289.995 91.491,294.944C92.152,297.752 93.744,300.15 95.9,301.835C97.112,303.244 104.625,312.076 110.742,321.061C118.78,332.869 126.685,347.139 127.645,348.672C127.839,348.982 143.393,360.014 149.53,346.845C150.846,344.022 157.59,329.043 164.665,317.255C170.219,308.001 176.358,301.47 177.409,300.175C180.954,295.806 180.359,292.005 180.307,291.276C179.984,286.736 177.758,283.807 174.801,281.852C174.068,281.367 165.353,275.232 159.519,265.769C152.099,253.733 148.152,237.601 147.429,235.705C147.028,234.656 129.217,221.311 124.037,236.585C123.163,239.16 118.769,253.368 111.533,265.176C106.401,273.55 96.882,281.62 96.401,282.024ZM135.382,273.164C136.36,275.065 137.403,276.933 138.507,278.724C142.231,284.764 146.762,289.875 150.8,293.788C148.449,296.919 145.853,300.629 143.499,304.552C141.358,308.12 139.13,312.198 137.035,316.22C135.147,313.203 133.15,310.113 131.147,307.17C127.521,301.843 123.848,296.963 120.681,292.996C124.699,288.907 129.082,283.782 132.58,278.074C133.545,276.5 134.482,274.851 135.382,273.164Z" style="fill:rgb(34,46,107);"></path> + <path d="M474.054,88.514C472.994,93.09 471.185,100.49 469.022,107.654C449.793,171.362 393.036,323.797 364.28,393.36C359.361,405.258 354.036,416.767 351.428,422.298C317.973,424.36 140.739,425.228 119.552,421.402C116.717,420.89 104.161,420.354 93.971,418.118C92.161,417.72 89.668,416.856 88.263,416.342C85.154,407.137 80.335,394.243 80.665,388.081C81.011,381.602 85.792,363.601 103.27,344.618C107.884,339.606 107.562,331.792 102.551,327.178C97.539,322.563 89.725,322.886 85.11,327.897C61.683,353.341 56.48,378.078 56.015,386.763C55.55,395.449 61.895,414.921 65.684,426.659C67.421,432.041 73.289,437.716 82.709,440.665C94.003,444.2 111.634,445.056 115.165,445.694C138.813,449.964 349.435,448.474 361.362,446.003C363.114,445.64 366.108,444.716 368.928,441.34C371.351,438.441 378.196,424.311 387.092,402.791C416.08,332.67 473.271,179.006 492.654,114.787C496.898,100.728 499.33,90.547 499.684,85.835C499.969,82.039 499.25,79.392 498.606,77.86C497.886,76.15 495.121,71.72 488.511,67.81C480.214,62.901 463.08,56.44 435.622,59.614C410.44,62.524 390.494,80.905 381.208,94.4C377.563,99.698 373.136,111.232 368.64,126.477C359.35,157.978 348.335,206.831 337.056,242.104C324.271,282.084 309.04,296.99 301.605,302.264C294.555,307.266 279.895,308.323 263.996,308.861C235.922,309.81 204.124,307.004 192.579,307.004C185.767,307.004 180.237,312.534 180.237,319.346C180.237,326.158 185.767,331.689 192.579,331.689C206.303,331.689 248.118,335.492 279.224,332.667C295.041,331.231 308.362,327.736 315.888,322.398C324.98,315.947 344.933,298.516 360.568,249.623C371.9,214.185 382.983,165.108 392.316,133.46C395.802,121.64 398.718,112.5 401.544,108.392C407.895,99.163 421.234,86.126 438.457,84.135C455.481,82.168 468.25,86.136 474.054,88.514Z" style="fill:rgb(34,46,107);"></path> + </g> + </g> +</svg> diff --git a/src/assets/images/invisible-wipers.svg b/src/assets/images/invisible-wipers.svg new file mode 100644 index 0000000..b5ac70b --- /dev/null +++ b/src/assets/images/invisible-wipers.svg @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"> + <rect id="Artboard1" x="0" y="0" width="512" height="512" style="fill:none;"></rect> + <g id="Artboard11" serif:id="Artboard1"> + <path d="M14.451,130.806C8.234,133.28 5.069,140.211 7.269,146.53C11.607,158.989 78.216,317.857 89.76,334.598C92.106,338 94.914,339.904 98.039,340.955C101.262,342.039 105.322,342.271 110.602,340.414C122.739,336.144 154.538,312.459 258.224,312.16C349.428,311.898 378.291,328.566 393.178,335.836C404.399,341.316 411.261,342.242 420.193,337.747C421.663,337.007 424.275,335.19 426.928,331.464C430.837,325.974 438.077,312.253 446.483,294.372C462.956,259.332 484.442,207.833 494.813,173.937C499.999,156.986 502.097,143.68 501.024,137.398C500.813,136.164 500.266,133.559 497.668,130.632C496.416,129.22 492.957,126.093 486.87,122.115C463.069,106.559 390.312,70.848 250.251,70.88C95.549,70.914 44.763,107.113 22.964,124.274C19.016,127.382 16.6,129.951 14.451,130.806ZM476.389,145.226C475.887,150.129 473.682,157.552 470.906,166.623C460.82,199.589 439.879,249.657 423.858,283.736C417.578,297.094 410.541,309.824 407.64,314.962C406.906,314.673 406.136,314.323 405.31,313.931C402.797,312.741 399.903,311.232 396.373,309.545C377.921,300.729 342.914,286.916 258.152,287.16C175.268,287.399 135.515,301.863 115.34,310.876C112.376,312.2 109.407,313.549 107.033,314.635C92.027,286.043 47.41,179.732 34.315,147.129L38.428,143.917C58.709,127.952 106.333,95.912 250.257,95.88C390.956,95.848 459.494,132.814 476.389,145.226Z" style="fill:rgb(34,46,107);"></path> + <path d="M346.934,285.768C377.331,239.29 407.797,192.3 424.81,167.417L404.173,153.307C387.098,178.28 356.519,225.438 326.011,272.084L346.934,285.768Z" style="fill:rgb(34,46,107);"></path> + <g transform="matrix(1,0,0,1,-9.92242,-12.951)"> + <circle cx="273.279" cy="385.687" r="28.294" style="fill:rgb(34,46,107);stroke:rgb(34,46,107);stroke-width:25px;"></circle> + </g> + <g transform="matrix(1.03187,0,0,1.03608,-8.54506,-11.1348)"> + <path d="M286.119,358.46C349.655,259.162 389.077,199.052 419.761,152.592L399.519,139.332C368.792,185.859 329.313,246.053 265.687,345.493L286.119,358.46Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(0.681025,0,0,0.605979,28.3015,43.4531)"> + <path d="M98.982,283.62C95.775,285.772 94.098,290.004 94.887,294.175C95.378,296.771 96.75,298.952 98.612,300.348C99.776,301.691 107.611,310.824 113.947,320.132C122.008,331.973 129.937,346.282 130.9,347.819C131.049,348.056 141.456,356.292 146.147,346.225C147.471,343.384 154.265,328.306 161.386,316.44C167.034,307.03 173.266,300.387 174.334,299.07C177.309,295.404 176.821,291.974 176.778,291.363C176.514,287.657 174.874,285.209 172.461,283.613C171.684,283.099 162.432,276.626 156.255,266.608C148.75,254.435 144.725,238.12 143.994,236.203C143.682,235.385 131.533,225.11 127.493,237.023C126.607,239.633 122.137,254.038 114.801,266.009C109.293,274.997 98.946,283.641 98.946,283.641L98.982,283.62ZM135.47,265.579C137.268,269.731 139.371,273.992 141.771,277.885C145.752,284.343 150.689,289.698 154.86,293.594C152.323,296.903 149.398,301.002 146.778,305.367C143.581,310.694 140.19,317.166 137.34,322.867C134.529,318.206 131.245,312.951 127.942,308.099C123.972,302.268 119.955,296.982 116.639,292.885C120.791,288.769 125.57,283.349 129.313,277.241C131.544,273.6 133.623,269.555 135.47,265.579Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(0.681025,0,0,0.605979,87.8122,-32.2914)"> + <path d="M98.982,283.62C95.775,285.772 94.098,290.004 94.887,294.175C95.378,296.771 96.75,298.952 98.612,300.348C99.776,301.691 107.611,310.824 113.947,320.132C122.008,331.973 129.937,346.282 130.9,347.819C131.049,348.056 141.456,356.292 146.147,346.225C147.471,343.384 154.265,328.306 161.386,316.44C167.034,307.03 173.266,300.387 174.334,299.07C177.309,295.404 176.821,291.974 176.778,291.363C176.514,287.657 174.874,285.209 172.461,283.613C171.684,283.099 162.432,276.626 156.255,266.608C148.75,254.435 144.725,238.12 143.994,236.203C143.682,235.385 131.533,225.11 127.493,237.023C126.607,239.633 122.137,254.038 114.801,266.009C109.293,274.997 98.946,283.641 98.946,283.641L98.982,283.62ZM135.47,265.579C137.268,269.731 139.371,273.992 141.771,277.885C145.752,284.343 150.689,289.698 154.86,293.594C152.323,296.903 149.398,301.002 146.778,305.367C143.581,310.694 140.19,317.166 137.34,322.867C134.529,318.206 131.245,312.951 127.942,308.099C123.972,302.268 119.955,296.982 116.639,292.885C120.791,288.769 125.57,283.349 129.313,277.241C131.544,273.6 133.623,269.555 135.47,265.579Z" style="fill:rgb(34,46,107);"></path> + </g> + </g> +</svg> diff --git a/src/assets/images/paint-restoration.svg b/src/assets/images/paint-restoration.svg new file mode 100644 index 0000000..499f8f0 --- /dev/null +++ b/src/assets/images/paint-restoration.svg @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" height="512px" viewBox="0 0 512.00002 512" width="512px" class=""><g><path d="m429.617188 218.304688h27.292968v30h-27.292968zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m468.195312 218.304688h43.804688v30h-43.804688zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m420.453125 178.257812 25.992187-8.324218 9.152344 28.570312-25.996094 8.324219zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m457.148438 166.507812 41.714843-13.363281 9.148438 28.570313-41.710938 13.359375zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m420.445312 288.324219 9.152344-28.574219 25.992188 8.324219-9.148438 28.574219zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m457.203125 300.101562 9.152344-28.570312 41.714843 13.359375-9.148437 28.570313zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m116 435.945312v-4.765624h29.753906l23.417969-66.902344h70.539063l22.882812-66.898438h112.417969v-48.8125h39.949219v-30h-39.949219v-49.332031h-181.84375v-17.992187c27.078125-6.539063 47.257812-30.957032 47.257812-60.015626v-91.226562h-124.515625v91.226562c0 29.058594 20.179688 53.476563 47.257813 60.015626v17.992187h-107.234375v20.015625h-55.933594v88.628906h55.933594v19.5h30.070312l-46.839844 133.800782h46.835938v4.765624c0 41.9375 34.117188 76.054688 76.054688 76.054688h349.945312v-30h-349.945312c-25.394532 0-46.054688-20.660156-46.054688-46.054688zm94.425781-405.945312v24.332031h-64.515625v-24.332031zm-64.515625 61.226562v-6.894531h64.515625v6.894531c0 17.507813-14.242187 31.75-31.75 31.75h-1.015625c-17.507812 0-31.75-14.242187-31.75-31.75zm72.355469 243.050782h-38.59375l12.917969-36.898438h38.300781zm126.746094-66.898438h-31.300781v-68.144531h31.300781zm-315.011719-19.5v-28.628906h25.933594v28.628906zm55.933594 19.5v-68.144531h197.777344v68.144531zm31.851562 30h43.019532l-36.335938 103.800782h-43.015625zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/></g> </svg> diff --git a/src/assets/images/ppf-protection.svg b/src/assets/images/ppf-protection.svg new file mode 100644 index 0000000..ceeafd0 --- /dev/null +++ b/src/assets/images/ppf-protection.svg @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;"> + <g id="Artboard1" transform="matrix(0.64,0,0,0.853333,0,0)"> + <rect x="0" y="0" width="800" height="600" style="fill:none;"></rect> + <g transform="matrix(2.19821,0,0,1.64865,-481.273,-153.382)"> + <path d="M297.409,419.448C317.041,418.871 343.045,420.914 364.476,416.046C376.846,413.237 387.79,408.184 395.936,399.88C404.26,391.393 409.901,379.484 410.675,362.483C412.203,328.943 410.814,142.009 410.814,117.894L390.912,117.894C390.912,141.919 392.315,328.161 390.793,361.577C390.292,372.573 387.111,380.454 381.727,385.943C376.164,391.614 368.516,394.719 360.068,396.638C339.863,401.227 315.333,399.01 296.825,399.554L297.409,419.448Z" style="fill:rgb(34,46,107);"></path> + <path d="M504.184,398.393C481.2,398.393 411.117,398.497 405.824,398.497L405.824,418.399C411.117,418.399 481.2,418.296 504.184,418.296L504.184,398.393Z" style="fill:rgb(34,46,107);"></path> + <g transform="matrix(-1,0,0,1,801.811,1.88619)"> + <path d="M235.402,281.429C248.373,262.89 244.778,257.746 247.814,244.103C252.488,223.102 258.567,199.154 264.324,183.867C267.322,175.905 270.513,170.012 273.22,167.031C277.102,162.759 284.94,159.396 296.008,158.056C314.832,155.777 345.124,157.812 373.854,157.569L374.022,177.471C349.907,177.675 324.668,176.177 306.341,177.155C300.889,177.446 296.118,177.943 292.302,178.858C290.396,179.316 288.774,179.509 287.951,180.415C285.574,183.031 283.313,189.381 280.659,197.393C275.885,211.804 271.084,231.158 267.242,248.426C263.61,264.745 267.224,270.663 251.71,292.838L235.402,281.429Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(-1,0,0,1,801.811,1.88619)"> + <path d="M243.413,302.071C243.574,318.153 244.996,404.888 245.48,434.222C258.356,434.328 281.17,434.493 295.78,434.476C296.087,432.968 296.562,430.351 296.701,428.04C297.138,420.786 296.953,410.774 297.179,397.547L317.079,397.887C316.713,419.292 317.06,432.88 314.95,440.341C313.626,445.023 311.44,448.246 308.722,450.475C305.768,452.896 302.02,454.369 296.992,454.378C275.911,454.416 235.597,454.04 235.597,454.04C230.202,453.99 225.83,449.649 225.74,444.255C225.74,444.255 223.619,316.476 223.498,301.146C223.464,296.818 226.161,291.134 230.728,285.882C233.79,282.362 239.252,276.344 239.252,276.344C239.252,276.344 239.075,276.748 239.015,276.934L257.961,283.031C256.843,286.503 252.335,291.797 247.609,296.883C246.108,298.497 244.051,301.217 243.413,302.071Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(-1,0,0,1.00201,802.177,-0.709201)"> + <path d="M373.2,215.331C361.526,215.331 329.372,215.128 314.514,215.03C314.514,215.03 300.345,268.397 300.345,268.397C321.044,268.208 360.305,267.864 372.641,267.864L372.641,287.727C355.651,287.727 287.477,288.381 287.477,288.381C284.371,288.411 281.43,286.992 279.524,284.545C277.618,282.097 276.966,278.903 277.762,275.906L297.25,202.504C298.413,198.124 302.399,195.085 306.938,195.116C306.938,195.116 357.837,195.468 373.2,195.468L373.2,215.331Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(1,0,0,1.00201,0,-0.566226)"> + <path d="M373.2,195.468C357.837,195.468 306.938,195.116 306.938,195.116C302.399,195.085 298.413,198.124 297.25,202.504L277.762,275.906C276.966,278.903 277.618,282.097 279.524,284.545C281.43,286.992 284.371,288.411 287.477,288.381C287.477,288.381 355.651,287.727 372.641,287.727L372.641,267.864C360.305,267.864 321.044,268.208 300.345,268.397C300.345,268.397 314.514,215.03 314.514,215.03C329.372,215.128 361.526,215.331 373.2,215.331L373.2,195.468Z" style="fill:rgb(34,46,107);"></path> + </g> + <g transform="matrix(0.822214,0,0,0.822214,248.747,53.5176)"> + <circle cx="304.294" cy="348.627" r="32.035" style="fill:none;stroke:rgb(34,46,107);stroke-width:24.21px;"></circle> + </g> + <g transform="matrix(0.822214,0,0,0.822214,52.6933,52.9143)"> + <circle cx="304.294" cy="348.627" r="32.035" style="fill:none;stroke:rgb(34,46,107);stroke-width:24.21px;"></circle> + </g> + <path d="M251.71,292.838C267.224,270.663 263.61,264.745 267.242,248.426C271.084,231.158 275.885,211.804 280.659,197.393C283.313,189.381 285.574,183.031 287.951,180.415C288.715,179.574 290.176,179.44 291.835,179.058C295.366,178.243 299.707,177.864 304.553,177.658C321.37,176.944 343.5,178.409 361.749,176.404C373.659,175.095 395.162,162.319 404.66,145.224C412.338,131.402 410.77,136.839 410.861,103.714L390.958,103.659C390.889,129.004 393.137,124.983 387.261,135.558C381.033,146.77 367.386,155.762 359.575,156.62C338.535,158.932 312.274,156.526 295.222,158.385C284.834,159.517 277.185,162.668 273.22,167.031C270.513,170.012 267.322,175.905 264.324,183.867C258.567,199.154 252.488,223.102 247.814,244.103C244.778,257.746 248.373,262.89 235.402,281.429L251.71,292.838Z" style="fill:rgb(34,46,107);"></path> + <path d="M295.723,434.476C281.11,434.492 258.339,434.327 245.48,434.222C244.995,404.837 243.569,317.851 243.412,301.989C244.121,301.137 246.64,298.135 248.249,296.484C251.922,292.714 254.884,288.832 255.905,285.658L236.959,279.561C237.014,279.392 237.167,279.042 237.167,279.042C237.167,279.042 232.554,283.932 230.068,286.735C225.933,291.397 223.462,296.549 223.498,301.146C223.619,316.476 225.74,444.255 225.74,444.255C225.83,449.649 230.202,453.99 235.597,454.04C235.597,454.04 275.911,454.416 296.992,454.378C301.837,454.369 305.396,453.116 308.143,451.144C311.104,449.018 313.399,445.921 314.693,441.222C316.533,434.536 315.767,421.877 315.55,400.309L295.648,400.509C295.717,407.267 295.875,413.095 295.992,418.095C296.125,423.731 296.214,428.247 296.02,431.808C295.972,432.684 295.848,433.666 295.723,434.476Z" style="fill:rgb(34,46,107);"></path> + </g> + </g> +</svg> diff --git a/src/assets/images/rzetelna-firma-logo.png b/src/assets/images/rzetelna-firma-logo.png Binary files differnew file mode 100644 index 0000000..cc9332a --- /dev/null +++ b/src/assets/images/rzetelna-firma-logo.png diff --git a/src/assets/images/visual-tuning.svg b/src/assets/images/visual-tuning.svg new file mode 100644 index 0000000..76afbd5 --- /dev/null +++ b/src/assets/images/visual-tuning.svg @@ -0,0 +1,2 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" height="512px" viewBox="0 0 512 512" width="512px"><g><path d="m238.789062 158.890625c-63.039062 0-114.320312 51.28125-114.320312 114.320313 0 63.039062 51.28125 114.320312 114.320312 114.320312 63.039063 0 114.320313-51.28125 114.320313-114.320312 0-63.039063-51.28125-114.320313-114.320313-114.320313zm0 198.640625c-46.488281 0-84.320312-37.832031-84.320312-84.320312 0-46.492188 37.832031-84.320313 84.320312-84.320313 46.492188 0 84.320313 37.828125 84.320313 84.320313 0 46.488281-37.828125 84.320312-84.320313 84.320312zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m238.789062 217.738281c-30.589843 0-55.46875 24.882813-55.46875 55.472657 0 30.589843 24.878907 55.46875 55.46875 55.46875 30.589844 0 55.472657-24.878907 55.472657-55.46875 0-30.589844-24.882813-55.472657-55.472657-55.472657zm0 80.941407c-14.050781 0-25.46875-11.417969-25.46875-25.46875 0-14.050782 11.417969-25.472657 25.46875-25.472657 14.050782 0 25.472657 11.421875 25.472657 25.472657 0 14.050781-11.421875 25.46875-25.472657 25.46875zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m256.332031 434.5c0 8.746094-7.085937 15.832031-15.832031 15.832031s-15.832031-7.085937-15.832031-15.832031 7.085937-15.832031 15.832031-15.832031 15.832031 7.085937 15.832031 15.832031zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m334.691406 404.0625c-4.375-7.570312-14.058594-10.160156-21.628906-5.785156s-10.164062 14.0625-5.785156 21.632812c4.375 7.570313 14.058594 10.160156 21.628906 5.785156s10.160156-14.0625 5.785156-21.632812zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m387.292969 338.566406c-7.574219-4.367187-17.257813-1.765625-21.621094 5.8125-4.367187 7.574219-1.765625 17.257813 5.8125 21.621094 7.574219 4.367188 17.257813 1.765625 21.621094-5.8125 4.367187-7.574219 1.765625-17.253906-5.8125-21.621094zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m169.941406 126.65625c-4.394531-7.558594-14.085937-10.125-21.644531-5.726562-7.5625 4.394531-10.125 14.085937-5.730469 21.644531 4.394532 7.558593 14.085938 10.125 21.644532 5.730469 7.558593-4.394532 10.125-14.085938 5.730468-21.648438zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m105.859375 180.789062c-7.589844-4.347656-17.261719-1.71875-21.609375 5.867188-4.347656 7.589844-1.71875 17.261719 5.867188 21.609375 7.585937 4.347656 17.261718 1.722656 21.609374-5.867187 4.347657-7.585938 1.71875-17.261719-5.867187-21.609376zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m93.339844 275.476562c-.035156-8.746093-7.148438-15.808593-15.894532-15.773437-8.742187.03125-15.804687 7.148437-15.773437 15.894531.035156 8.742188 7.148437 15.804688 15.894531 15.769532 8.746094-.03125 15.804688-7.148438 15.773438-15.890626zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m114.003906 347.941406c-4.402344-7.554687-14.097656-10.105468-21.652344-5.703125-7.554687 4.40625-10.109374 14.101563-5.703124 21.652344 4.40625 7.554687 14.097656 10.109375 21.652343 5.703125 7.554688-4.402344 10.109375-14.097656 5.703125-21.652344zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m168.121094 400.386719c-7.589844-4.335938-17.261719-1.699219-21.601563 5.894531-4.335937 7.59375-1.695312 17.261719 5.894531 21.601562 7.59375 4.335938 17.265626 1.695313 21.601563-5.894531 4.339844-7.59375 1.699219-17.265625-5.894531-21.601562zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/><path d="m431.980469 80.019531c-51.601563-51.597656-120.210938-80.019531-193.191407-80.019531h-15v34.878906c-58.140624 3.582032-112.300781 27.929688-153.847656 69.480469-45.101562 45.101563-69.941406 105.070313-69.941406 168.851563 0 63.777343 24.839844 123.75 69.941406 168.847656 45.097656 45.101562 105.070313 69.941406 168.847656 69.941406 63.78125 0 123.75-24.839844 168.851563-69.941406 40.578125-40.578125 64.75-93.1875 69.1875-149.777344h34.882813l.25-15.660156c.019531-1.132813.039062-2.269532.039062-3.410156 0-72.980469-28.421875-141.589844-80.019531-193.191407zm-15.351563 182.261719c-5.359375-88.203125-75.179687-159.28125-162.839844-166.621094v-65.199218c123.539063 7.550781 222.449219 107.75 227.972657 231.820312zm-177.839844 219.71875c-115.128906 0-208.789062-93.660156-208.789062-208.789062 0-110.089844 85.628906-200.539063 193.789062-208.25v60.078124h15c81.699219 0 148.171876 66.472657 148.171876 148.171876 0 1.238281-.011719 2.46875-.039063 3.699218l-.382813 15.371094h60.171876c-9.652344 106.21875-99.222657 189.71875-207.921876 189.71875zm0 0" data-original="#000000" class="active-path" data-old_color="#000000" fill="#222E6B"/></g> </svg> diff --git a/src/assets/styles/tailwind.css b/src/assets/styles/tailwind.css new file mode 100644 index 0000000..a086ee1 --- /dev/null +++ b/src/assets/styles/tailwind.css @@ -0,0 +1,95 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .bg-page { + background-color: var(--aw-color-bg-page); + } + .bg-dark { + background-color: var(--aw-color-bg-page-dark); + } + .bg-light { + background-color: var(--aw-color-bg-page); + } + .bg-section { + background-color: var(--aw-color-bg-section); + } + .text-page { + color: var(--aw-color-text-page); + } + .text-muted { + color: var(--aw-color-text-muted); + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center rounded-full border-gray-400 border bg-transparent font-medium text-center text-base text-page leading-snug transition py-3.5 px-6 md:px-8 ease-in duration-200 focus:ring-primary focus:ring-offset-2 focus:ring-2 focus:ring-offset-2 hover:bg-gray-100 hover:border-primary dark:text-slate-300 dark:border-slate-500 dark:hover:bg-slate-800 dark:hover:border-slate-800 cursor-pointer; + } + + .btn-primary { + @apply btn font-semibold bg-primary text-white border-primary hover:bg-secondary hover:border-secondary hover:text-white dark:text-white dark:bg-primary dark:border-primary dark:hover:border-secondary dark:hover:bg-secondary; + } + + .btn-secondary { + @apply btn; + } + + .btn-tertiary { + @apply btn border-none shadow-none text-muted hover:text-default dark:text-gray-400 dark:hover:text-white; + } +} + +#header.scroll > div:first-child { + @apply bg-page md:bg-white/90 md:backdrop-blur-md; + box-shadow: 0 0.375rem 1.5rem 0 rgb(140 152 164 / 13%); +} +.dark #header.scroll > div:first-child, +#header.scroll.dark > div:first-child { + @apply bg-page md:bg-[#030621e6] border-b border-gray-500/20; + box-shadow: none; +} +/* #header.scroll > div:last-child { + @apply py-3; +} */ + +#header.expanded nav { + position: fixed; + top: 70px; + left: 0; + right: 0; + bottom: 70px !important; + padding: 0 5px; +} + +.dropdown:focus .dropdown-menu, +.dropdown:focus-within .dropdown-menu, +.dropdown:hover .dropdown-menu { + display: block; +} + +[astro-icon].icon-light > * { + stroke-width: 1.2; +} + +[astro-icon].icon-bold > * { + stroke-width: 2.4; +} + +[data-aw-toggle-menu] path { + @apply transition; +} +[data-aw-toggle-menu].expanded g > path:first-child { + @apply -rotate-45 translate-y-[15px] translate-x-[-3px]; +} + +[data-aw-toggle-menu].expanded g > path:last-child { + @apply rotate-45 translate-y-[-8px] translate-x-[14px]; +} + +/* To deprecated */ + +.dd *:first-child { + margin-top: 0; +} diff --git a/src/components/CustomStyles.astro b/src/components/CustomStyles.astro new file mode 100644 index 0000000..9798cb6 --- /dev/null +++ b/src/components/CustomStyles.astro @@ -0,0 +1,69 @@ +--- +import '@fontsource/kanit'; +import '@fontsource/poppins'; + +// 'DM Sans' +// Nunito +// Dosis +// Outfit +// Roboto +// Literata +// 'IBM Plex Sans' +// Karla +// Poppins +// 'Fira Sans' +// 'Libre Franklin' +// Inconsolata +// Raleway +// Oswald +// 'Space Grotesk' +// Urbanist +--- + +<style is:inline> + :root { + --aw-font-sans: 'Poppins'; + --aw-font-serif: 'Poppins'; + --aw-font-heading: 'Kanit'; + + /* New color palette */ + --aw-color-primary: #162130; /* Głęboki granat - główny kolor */ + --aw-color-secondary: #162130; /* Głęboki granat - główny kolor */ + --aw-color-accent: #162130; /* Głęboki granat - główny kolor */ + + --aw-color-text-heading: #162130; /* Głęboki granat dla nagłówków */ + --aw-color-text-default: #000000; /* Czerń dla tekstu na jasnym tle */ + --aw-color-text-muted: #888888; /* Stonowana szarość */ + --aw-color-bg-page: #FFFFFF; /* Klasyczna biel */ + --aw-color-bg-section: #FFFFFF; /* Klasyczna biel */ + + --aw-color-bg-page-dark: #162130; /* Głęboki granat */ + + ::selection { + background-color: #162130; + color: #FFFFFF; + } + } + + .dark { + --aw-font-sans: 'Poppins'; + --aw-font-serif: 'Poppins'; + --aw-font-heading: 'Kanit'; + + /* Dark mode */ + --aw-color-primary: #FFFFFF; /* Biały dla kontrastu na ciemnym tle */ + --aw-color-secondary: #FFFFFF; /* Biały dla kontrastu na ciemnym tle */ + --aw-color-accent: #FFFFFF; /* Biały dla kontrastu na ciemnym tle */ + + --aw-color-text-heading: #CCCCCC; /* Jasna szarość na ciemnym tle */ + --aw-color-text-default: #CCCCCC; /* Jasna szarość na ciemnym tle */ + --aw-color-text-muted: #888888; /* Stonowana szarość */ + --aw-color-bg-page: #162130; /* Głęboki granat */ + --aw-color-bg-section: #FFFFFF; /* Klasyczna biel dla jasnych sekcji */ + + ::selection { + background-color: #FFFFFF; + color: #162130; + } + } +</style> diff --git a/src/components/Favicons.astro b/src/components/Favicons.astro new file mode 100644 index 0000000..fed6696 --- /dev/null +++ b/src/components/Favicons.astro @@ -0,0 +1,10 @@ +--- +import favIcon from '~/assets/favicons/favicon.ico'; +import favIconSvg from '~/assets/favicons/favicon.svg'; +import appleTouchIcon from '~/assets/favicons/apple-touch-icon.png'; +--- + +<link rel="shortcut icon" href={favIcon} /> +<link rel="icon" type="image/svg+xml" href={favIconSvg.src} /> +<link rel="mask-icon" href={favIconSvg.src} color="#8D46E7" /> +<link rel="apple-touch-icon" sizes="180x180" href={appleTouchIcon.src} /> diff --git a/src/components/Logo.astro b/src/components/Logo.astro new file mode 100644 index 0000000..8469792 --- /dev/null +++ b/src/components/Logo.astro @@ -0,0 +1,9 @@ +--- +import { SITE } from 'astrowind:config'; +--- + +<span + class="self-center ml-2 rtl:ml-0 rtl:mr-2 text-2xl md:text-xl font-bold text-gray-900 whitespace-nowrap dark:text-white" +> + 🚀 {SITE?.name} +</span> diff --git a/src/components/blog/Grid.astro b/src/components/blog/Grid.astro new file mode 100644 index 0000000..1b62be4 --- /dev/null +++ b/src/components/blog/Grid.astro @@ -0,0 +1,14 @@ +--- +import Item from '~/components/blog/GridItem.astro'; +import type { Post } from '~/types'; + +export interface Props { + posts: Array<Post>; +} + +const { posts } = Astro.props; +--- + +<div class="grid gap-6 row-gap-5 md:grid-cols-2 lg:grid-cols-4 -mb-6"> + {posts.map((post) => <Item post={post} />)} +</div> diff --git a/src/components/blog/GridItem.astro b/src/components/blog/GridItem.astro new file mode 100644 index 0000000..73353ca --- /dev/null +++ b/src/components/blog/GridItem.astro @@ -0,0 +1,71 @@ +--- +import { APP_BLOG } from 'astrowind:config'; +import type { Post } from '~/types'; + +import Image from '~/components/common/Image.astro'; + +import { findImage } from '~/utils/images'; +import { getPermalink } from '~/utils/permalinks'; + +export interface Props { + post: Post; +} + +const { post } = Astro.props; +const image = await findImage(post.image); + +const link = APP_BLOG?.post?.isEnabled ? getPermalink(post.permalink, 'post') : ''; +--- + +<article + class="mb-6 transition intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" +> + <div class="relative md:h-64 bg-gray-400 dark:bg-slate-700 rounded shadow-lg mb-6"> + { + image && + (link ? ( + <a href={link}> + <Image + src={image} + class="w-full md:h-full rounded shadow-lg bg-gray-400 dark:bg-slate-700" + widths={[400, 900]} + width={400} + sizes="(max-width: 900px) 400px, 900px" + alt={post.title} + aspectRatio="16:9" + layout="cover" + loading="lazy" + decoding="async" + /> + </a> + ) : ( + <Image + src={image} + class="w-full md:h-full rounded shadow-lg bg-gray-400 dark:bg-slate-700" + widths={[400, 900]} + width={400} + sizes="(max-width: 900px) 400px, 900px" + alt={post.title} + aspectRatio="16:9" + layout="cover" + loading="lazy" + decoding="async" + /> + )) + } + </div> + + <h3 class="text-xl sm:text-2xl font-bold leading-tight mb-2 font-heading dark:text-slate-300"> + { + link ? ( + <a class="inline-block hover:text-primary dark:hover:text-secondary transition ease-in duration-200" href={link}> + {post.title} + </a> + ) : ( + post.title + ) + } + </h3> + + <p class="text-muted dark:text-slate-400 text-lg">{post.excerpt}</p> +</article> diff --git a/src/components/blog/Headline.astro b/src/components/blog/Headline.astro new file mode 100644 index 0000000..5d3ccc6 --- /dev/null +++ b/src/components/blog/Headline.astro @@ -0,0 +1,12 @@ +--- +const { title = await Astro.slots.render('default'), subtitle = await Astro.slots.render('subtitle') } = Astro.props; +--- + +<header class="mb-8 md:mb-16 text-center max-w-3xl mx-auto"> + <h1 class="text-4xl md:text-5xl font-bold leading-tighter tracking-tighter font-heading" set:html={title} /> + { + subtitle && ( + <div class="mt-2 md:mt-3 mx-auto text-xl text-gray-500 dark:text-slate-400 font-medium" set:html={subtitle} /> + ) + } +</header> diff --git a/src/components/blog/List.astro b/src/components/blog/List.astro new file mode 100644 index 0000000..6a80ae3 --- /dev/null +++ b/src/components/blog/List.astro @@ -0,0 +1,20 @@ +--- +import Item from '~/components/blog/ListItem.astro'; +import type { Post } from '~/types'; + +export interface Props { + posts: Array<Post>; +} + +const { posts } = Astro.props; +--- + +<ul> + { + posts.map((post) => ( + <li class="mb-12 md:mb-20"> + <Item post={post} /> + </li> + )) + } +</ul> diff --git a/src/components/blog/ListItem.astro b/src/components/blog/ListItem.astro new file mode 100644 index 0000000..36602f2 --- /dev/null +++ b/src/components/blog/ListItem.astro @@ -0,0 +1,120 @@ +--- +import type { ImageMetadata } from 'astro'; +import { Icon } from 'astro-icon/components'; +import Image from '~/components/common/Image.astro'; +import PostTags from '~/components/blog/Tags.astro'; + +import { APP_BLOG } from 'astrowind:config'; +import type { Post } from '~/types'; + +import { getPermalink } from '~/utils/permalinks'; +import { findImage } from '~/utils/images'; +import { getFormattedDate } from '~/utils/utils'; + +export interface Props { + post: Post; +} + +const { post } = Astro.props; +const image = (await findImage(post.image)) as ImageMetadata | undefined; + +const link = APP_BLOG?.post?.isEnabled ? getPermalink(post.permalink, 'post') : ''; +--- + +<article + class={`max-w-md mx-auto md:max-w-none grid gap-6 md:gap-8 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade ${image ? 'md:grid-cols-2' : ''}`} +> + { + image && + (link ? ( + <a class="relative block group" href={link ?? 'javascript:void(0)'}> + <div class="relative h-0 pb-[56.25%] md:pb-[75%] md:h-72 lg:pb-[56.25%] overflow-hidden bg-gray-400 dark:bg-slate-700 rounded shadow-lg"> + {image && ( + <Image + src={image} + class="absolute inset-0 object-cover w-full h-full mb-6 rounded shadow-lg bg-gray-400 dark:bg-slate-700" + widths={[400, 900]} + width={900} + sizes="(max-width: 900px) 400px, 900px" + alt={post.title} + aspectRatio="16:9" + loading="lazy" + decoding="async" + /> + )} + </div> + </a> + ) : ( + <div class="relative h-0 pb-[56.25%] md:pb-[75%] md:h-72 lg:pb-[56.25%] overflow-hidden bg-gray-400 dark:bg-slate-700 rounded shadow-lg"> + {image && ( + <Image + src={image} + class="absolute inset-0 object-cover w-full h-full mb-6 rounded shadow-lg bg-gray-400 dark:bg-slate-700" + widths={[400, 900]} + width={900} + sizes="(max-width: 900px) 400px, 900px" + alt={post.title} + aspectRatio="16:9" + loading="lazy" + decoding="async" + /> + )} + </div> + )) + } + <div class="mt-2"> + <header> + <div class="mb-1"> + <span class="text-sm"> + <Icon name="tabler:clock" class="w-3.5 h-3.5 inline-block -mt-0.5 dark:text-gray-400" /> + <time datetime={String(post.publishDate)} class="inline-block">{getFormattedDate(post.publishDate)}</time> + { + post.author && ( + <> + {' '} + · <Icon name="tabler:user" class="w-3.5 h-3.5 inline-block -mt-0.5 dark:text-gray-400" /> + <span>{post.author.replaceAll('-', ' ')}</span> + </> + ) + } + { + post.category && ( + <> + {' '} + ·{' '} + <a class="hover:underline" href={getPermalink(post.category.slug, 'category')}> + {post.category.title} + </a> + </> + ) + } + </span> + </div> + <h2 class="text-xl sm:text-2xl font-bold leading-tight mb-2 font-heading dark:text-slate-300"> + { + link ? ( + <a + class="inline-block hover:text-primary dark:hover:text-secondary transition ease-in duration-200" + href={link} + > + {post.title} + </a> + ) : ( + post.title + ) + } + </h2> + </header> + + {post.excerpt && <p class="flex-grow text-muted dark:text-slate-400 text-lg">{post.excerpt}</p>} + { + post.tags && Array.isArray(post.tags) ? ( + <footer class="mt-5"> + <PostTags tags={post.tags} /> + </footer> + ) : ( + <Fragment /> + ) + } + </div> +</article> diff --git a/src/components/blog/Pagination.astro b/src/components/blog/Pagination.astro new file mode 100644 index 0000000..051587c --- /dev/null +++ b/src/components/blog/Pagination.astro @@ -0,0 +1,36 @@ +--- +import { Icon } from 'astro-icon/components'; +import { getPermalink } from '~/utils/permalinks'; +import Button from '~/components/ui/Button.astro'; + +export interface Props { + prevUrl?: string; + nextUrl?: string; + prevText?: string; + nextText?: string; +} + +const { prevUrl, nextUrl, prevText = 'Newer posts', nextText = 'Older posts' } = Astro.props; +--- + +{ + (prevUrl || nextUrl) && ( + <div class="container flex"> + <div class="flex flex-row mx-auto container justify-between"> + <Button + variant="tertiary" + class={`md:px-3 px-3 mr-2 ${!prevUrl ? 'invisible' : ''}`} + href={getPermalink(prevUrl)} + > + <Icon name="tabler:chevron-left" class="w-6 h-6" /> + <p class="ml-2">{prevText}</p> + </Button> + + <Button variant="tertiary" class={`md:px-3 px-3 ${!nextUrl ? 'invisible' : ''}`} href={getPermalink(nextUrl)}> + <span class="mr-2">{nextText}</span> + <Icon name="tabler:chevron-right" class="w-6 h-6" /> + </Button> + </div> + </div> + ) +} diff --git a/src/components/blog/RelatedPosts.astro b/src/components/blog/RelatedPosts.astro new file mode 100644 index 0000000..f4036e9 --- /dev/null +++ b/src/components/blog/RelatedPosts.astro @@ -0,0 +1,31 @@ +--- +import { APP_BLOG } from 'astrowind:config'; + +import { getRelatedPosts } from '~/utils/blog'; +import BlogHighlightedPosts from '../widgets/BlogHighlightedPosts.astro'; +import type { Post } from '~/types'; +import { getBlogPermalink } from '~/utils/permalinks'; + +export interface Props { + post: Post; +} + +const { post } = Astro.props; + +const relatedPosts = post.tags ? await getRelatedPosts(post, 4) : []; +--- + +{ + APP_BLOG.isRelatedPostsEnabled ? ( + <BlogHighlightedPosts + classes={{ + container: + 'pt-0 lg:pt-0 md:pt-0 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade', + }} + title="Related Posts" + linkText="View All Posts" + linkUrl={getBlogPermalink()} + postIds={relatedPosts.map((post) => post.id)} + /> + ) : null +} diff --git a/src/components/blog/SinglePost.astro b/src/components/blog/SinglePost.astro new file mode 100644 index 0000000..ac92cd3 --- /dev/null +++ b/src/components/blog/SinglePost.astro @@ -0,0 +1,103 @@ +--- +import { Icon } from 'astro-icon/components'; + +import Image from '~/components/common/Image.astro'; +import PostTags from '~/components/blog/Tags.astro'; +import SocialShare from '~/components/common/SocialShare.astro'; + +import { getPermalink } from '~/utils/permalinks'; +import { getFormattedDate } from '~/utils/utils'; + +import type { Post } from '~/types'; + +export interface Props { + post: Post; + url: string | URL; +} + +const { post, url } = Astro.props; +--- + +<section class="py-8 sm:py-16 lg:py-20 mx-auto"> + <article> + <header + class={post.image + ? 'intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade' + : 'intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade'} + > + <div class="flex justify-between flex-col sm:flex-row max-w-3xl mx-auto mt-0 mb-2 px-4 sm:px-6 sm:items-center"> + <p> + <Icon name="tabler:clock" class="w-4 h-4 inline-block -mt-0.5 dark:text-gray-400" /> + <time datetime={String(post.publishDate)} class="inline-block">{getFormattedDate(post.publishDate)}</time> + { + post.author && ( + <> + {' '} + · <Icon name="tabler:user" class="w-4 h-4 inline-block -mt-0.5 dark:text-gray-400" /> + <span class="inline-block">{post.author}</span> + </> + ) + } + { + post.category && ( + <> + {' '} + ·{' '} + <a class="hover:underline inline-block" href={getPermalink(post.category.slug, 'category')}> + {post.category.title} + </a> + </> + ) + } + { + post.readingTime && ( + <> + · <span>{post.readingTime}</span> min read + </> + ) + } + </p> + </div> + + <h1 + class="px-4 sm:px-6 max-w-3xl mx-auto text-4xl md:text-5xl font-bold leading-tighter tracking-tighter font-heading" + > + {post.title} + </h1> + <p + class="max-w-3xl mx-auto mt-4 mb-8 px-4 sm:px-6 text-xl md:text-2xl text-muted dark:text-slate-400 text-justify" + > + {post.excerpt} + </p> + + { + post.image ? ( + <Image + src={post.image} + class="max-w-full lg:max-w-[900px] mx-auto mb-6 sm:rounded-md bg-gray-400 dark:bg-slate-700" + widths={[400, 900]} + sizes="(max-width: 900px) 400px, 900px" + alt={post?.excerpt || ''} + width={900} + height={506} + loading="eager" + decoding="async" + /> + ) : ( + <div class="max-w-3xl mx-auto px-4 sm:px-6"> + <div class="border-t dark:border-slate-700" /> + </div> + ) + } + </header> + <div + class="mx-auto px-6 sm:px-6 max-w-3xl prose prose-md lg:prose-xl dark:prose-invert dark:prose-headings:text-slate-300 prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-primary dark:prose-a:text-primary prose-img:rounded-md prose-img:shadow-lg mt-8 prose-headings:scroll-mt-[80px] prose-li:my-0" + > + <slot /> + </div> + <div class="mx-auto px-6 sm:px-6 max-w-3xl mt-8 flex justify-between flex-col sm:flex-row"> + <PostTags tags={post.tags} class="mr-5 rtl:mr-0 rtl:ml-5" /> + <SocialShare url={url} text={post.title} class="mt-5 sm:mt-1 align-middle text-gray-500 dark:text-slate-600" /> + </div> + </article> +</section> diff --git a/src/components/blog/Tags.astro b/src/components/blog/Tags.astro new file mode 100644 index 0000000..ae46a24 --- /dev/null +++ b/src/components/blog/Tags.astro @@ -0,0 +1,43 @@ +--- +import { getPermalink } from '~/utils/permalinks'; + +import { APP_BLOG } from 'astrowind:config'; +import type { Post } from '~/types'; + +export interface Props { + tags: Post['tags']; + class?: string; + title?: string | undefined; + isCategory?: boolean; +} + +const { tags, class: className = 'text-sm', title = undefined, isCategory = false } = Astro.props; +--- + +{ + tags && Array.isArray(tags) && ( + <> + {title !== undefined && ( + <span class="align-super font-normal underline underline-offset-4 decoration-2 dark:text-slate-400"> + {title} + </span> + )} + <ul class={className}> + {tags.map((tag) => ( + <li class="bg-gray-100 dark:bg-slate-700 inline-block mr-2 rtl:mr-0 rtl:ml-2 mb-2 py-0.5 px-2 lowercase font-medium"> + {!APP_BLOG?.tag?.isEnabled ? ( + tag.title + ) : ( + <a + href={getPermalink(tag.slug, isCategory ? 'category' : 'tag')} + class="text-muted dark:text-slate-300 hover:text-primary dark:hover:text-gray-200" + > + {tag.title} + </a> + )} + </li> + ))} + </ul> + </> + ) +} diff --git a/src/components/blog/ToBlogLink.astro b/src/components/blog/ToBlogLink.astro new file mode 100644 index 0000000..7fb7a49 --- /dev/null +++ b/src/components/blog/ToBlogLink.astro @@ -0,0 +1,20 @@ +--- +import { Icon } from 'astro-icon/components'; +import { getBlogPermalink } from '~/utils/permalinks'; +import { I18N } from 'astrowind:config'; +import Button from '~/components/ui/Button.astro'; + +const { textDirection } = I18N; +--- + +<div class="mx-auto px-6 sm:px-6 max-w-3xl pt-8 md:pt-4 pb-12 md:pb-20"> + <Button variant="tertiary" class="px-3 md:px-3" href={getBlogPermalink()}> + { + textDirection === 'rtl' ? ( + <Icon name="tabler:chevron-right" class="w-5 h-5 mr-1 -ml-1.5 rtl:-mr-1.5 rtl:ml-1" /> + ) : ( + <Icon name="tabler:chevron-left" class="w-5 h-5 mr-1 -ml-1.5 rtl:-mr-1.5 rtl:ml-1" /> + ) + } Back to Blog + </Button> +</div> diff --git a/src/components/common/Analytics.astro b/src/components/common/Analytics.astro new file mode 100644 index 0000000..a1a553d --- /dev/null +++ b/src/components/common/Analytics.astro @@ -0,0 +1,13 @@ +--- +import { GoogleAnalytics } from '@astrolib/analytics'; +import { ANALYTICS } from 'astrowind:config'; +--- + +{ + ANALYTICS?.vendors?.googleAnalytics?.id ? ( + <GoogleAnalytics + id={String(ANALYTICS.vendors.googleAnalytics.id)} + partytown={ANALYTICS?.vendors?.googleAnalytics?.partytown} + /> + ) : null +} diff --git a/src/components/common/ApplyColorMode.astro b/src/components/common/ApplyColorMode.astro new file mode 100644 index 0000000..d0d97fe --- /dev/null +++ b/src/components/common/ApplyColorMode.astro @@ -0,0 +1,33 @@ +--- +import { UI } from 'astrowind:config'; + +// TODO: This code is temporary +--- + +<script is:inline define:vars={{ defaultTheme: UI.theme || 'system' }}> + function applyTheme(theme) { + if (theme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + const matches = document.querySelectorAll('[data-aw-toggle-color-scheme] > input'); + + if (matches && matches.length) { + matches.forEach((elem) => { + elem.checked = theme !== 'dark'; + }); + } + } + + if ((defaultTheme && defaultTheme.endsWith(':only')) || (!localStorage.theme && defaultTheme !== 'system')) { + applyTheme(defaultTheme.replace(':only', '')); + } else if ( + localStorage.theme === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { + applyTheme('dark'); + } else { + applyTheme('light'); + } +</script> diff --git a/src/components/common/BasicScripts.astro b/src/components/common/BasicScripts.astro new file mode 100644 index 0000000..c7290b2 --- /dev/null +++ b/src/components/common/BasicScripts.astro @@ -0,0 +1,255 @@ +--- +import { UI } from 'astrowind:config'; +--- + +<script is:inline define:vars={{ defaultTheme: UI.theme }}> + if (window.basic_script) { + return; + } + + window.basic_script = true; + + function applyTheme(theme) { + if (theme === 'dark') { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + } + + const initTheme = function () { + if ((defaultTheme && defaultTheme.endsWith(':only')) || (!localStorage.theme && defaultTheme !== 'system')) { + applyTheme(defaultTheme.replace(':only', '')); + } else if ( + localStorage.theme === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { + applyTheme('dark'); + } else { + applyTheme('light'); + } + }; + initTheme(); + + function attachEvent(selector, event, fn) { + const matches = typeof selector === 'string' ? document.querySelectorAll(selector) : selector; + if (matches && matches.length) { + matches.forEach((elem) => { + elem.addEventListener(event, (e) => fn(e, elem), false); + }); + } + } + + const onLoad = function () { + let lastKnownScrollPosition = window.scrollY; + let ticking = true; + + attachEvent('#header nav', 'click', function () { + document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded'); + document.body.classList.remove('overflow-hidden'); + document.getElementById('header')?.classList.remove('h-screen'); + document.getElementById('header')?.classList.remove('expanded'); + document.getElementById('header')?.classList.remove('bg-page'); + document.querySelector('#header nav')?.classList.add('hidden'); + document.querySelector('#header > div > div:last-child')?.classList.add('hidden'); + }); + + attachEvent('[data-aw-toggle-menu]', 'click', function (_, elem) { + elem.classList.toggle('expanded'); + document.body.classList.toggle('overflow-hidden'); + document.getElementById('header')?.classList.toggle('h-screen'); + document.getElementById('header')?.classList.toggle('expanded'); + document.getElementById('header')?.classList.toggle('bg-page'); + document.querySelector('#header nav')?.classList.toggle('hidden'); + document.querySelector('#header > div > div:last-child')?.classList.toggle('hidden'); + }); + + attachEvent('[data-aw-toggle-color-scheme]', 'click', function () { + if (defaultTheme.endsWith(':only')) { + return; + } + document.documentElement.classList.toggle('dark'); + localStorage.theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; + }); + + attachEvent('[data-aw-social-share]', 'click', function (_, elem) { + const network = elem.getAttribute('data-aw-social-share'); + const url = encodeURIComponent(elem.getAttribute('data-aw-url')); + const text = encodeURIComponent(elem.getAttribute('data-aw-text')); + + let href; + switch (network) { + case 'facebook': + href = `https://www.facebook.com/sharer.php?u=${url}`; + break; + case 'twitter': + href = `https://twitter.com/intent/tweet?url=${url}&text=${text}`; + break; + case 'linkedin': + href = `https://www.linkedin.com/shareArticle?mini=true&url=${url}&title=${text}`; + break; + case 'whatsapp': + href = `https://wa.me/?text=${text}%20${url}`; + break; + case 'mail': + href = `mailto:?subject=%22${text}%22&body=${text}%20${url}`; + break; + + default: + return; + } + + const newlink = document.createElement('a'); + newlink.target = '_blank'; + newlink.href = href; + newlink.click(); + }); + + const screenSize = window.matchMedia('(max-width: 767px)'); + screenSize.addEventListener('change', function () { + document.querySelector('[data-aw-toggle-menu]')?.classList.remove('expanded'); + document.body.classList.remove('overflow-hidden'); + document.getElementById('header')?.classList.remove('h-screen'); + document.getElementById('header')?.classList.remove('expanded'); + document.getElementById('header')?.classList.remove('bg-page'); + document.querySelector('#header nav')?.classList.add('hidden'); + document.querySelector('#header > div > div:last-child')?.classList.add('hidden'); + }); + + function applyHeaderStylesOnScroll() { + const header = document.querySelector('#header[data-aw-sticky-header]'); + if (!header) return; + if (lastKnownScrollPosition > 60 && !header.classList.contains('scroll')) { + header.classList.add('scroll'); + } else if (lastKnownScrollPosition <= 60 && header.classList.contains('scroll')) { + header.classList.remove('scroll'); + } + ticking = false; + } + applyHeaderStylesOnScroll(); + + attachEvent([document], 'scroll', function () { + lastKnownScrollPosition = window.scrollY; + + if (!ticking) { + window.requestAnimationFrame(() => { + applyHeaderStylesOnScroll(); + }); + ticking = true; + } + }); + }; + const onPageShow = function () { + document.documentElement.classList.add('motion-safe:scroll-smooth'); + const elem = document.querySelector('[data-aw-toggle-menu]'); + if (elem) { + elem.classList.remove('expanded'); + } + document.body.classList.remove('overflow-hidden'); + document.getElementById('header')?.classList.remove('h-screen'); + document.getElementById('header')?.classList.remove('expanded'); + document.querySelector('#header nav')?.classList.add('hidden'); + }; + + window.onload = onLoad; + window.onpageshow = onPageShow; + + document.addEventListener('astro:after-swap', () => { + initTheme(); + onLoad(); + onPageShow(); + }); +</script> + +<script is:inline> + /* Inspired by: https://github.com/heidkaemper/tailwindcss-intersect */ + const Observer = { + observer: null, + delayBetweenAnimations: 100, + animationCounter: 0, + + start() { + const selectors = [ + '[class*=" intersect:"]', + '[class*=":intersect:"]', + '[class^="intersect:"]', + '[class="intersect"]', + '[class*=" intersect "]', + '[class^="intersect "]', + '[class$=" intersect"]', + ]; + + const elements = Array.from(document.querySelectorAll(selectors.join(','))); + + const getThreshold = (element) => { + if (element.classList.contains('intersect-full')) return 0.99; + if (element.classList.contains('intersect-half')) return 0.5; + if (element.classList.contains('intersect-quarter')) return 0.25; + return 0; + }; + + elements.forEach((el) => { + el.setAttribute('no-intersect', ''); + el._intersectionThreshold = getThreshold(el); + }); + + const callback = (entries) => { + entries.forEach((entry) => { + requestAnimationFrame(() => { + const target = entry.target; + const intersectionRatio = entry.intersectionRatio; + const threshold = target._intersectionThreshold; + + if (target.classList.contains('intersect-no-queue')) { + if (entry.isIntersecting) { + target.removeAttribute('no-intersect'); + if (target.classList.contains('intersect-once')) { + this.observer.unobserve(target); + } + } else { + target.setAttribute('no-intersect', ''); + } + return; + } + + if (intersectionRatio >= threshold) { + if (!target.hasAttribute('data-animated')) { + target.removeAttribute('no-intersect'); + target.setAttribute('data-animated', 'true'); + + const delay = this.animationCounter * this.delayBetweenAnimations; + this.animationCounter++; + + target.style.transitionDelay = `${delay}ms`; + target.style.animationDelay = `${delay}ms`; + + if (target.classList.contains('intersect-once')) { + this.observer.unobserve(target); + } + } + } else { + target.setAttribute('no-intersect', ''); + target.removeAttribute('data-animated'); + target.style.transitionDelay = ''; + target.style.animationDelay = ''; + + this.animationCounter = 0; + } + }); + }); + }; + + this.observer = new IntersectionObserver(callback.bind(this), { threshold: [0, 0.25, 0.5, 0.99] }); + + elements.forEach((el) => { + this.observer.observe(el); + }); + }, + }; + + Observer.start(); + + document.addEventListener('astro:after-swap', () => { + Observer.start(); + }); +</script> diff --git a/src/components/common/CommonMeta.astro b/src/components/common/CommonMeta.astro new file mode 100644 index 0000000..aab6dd4 --- /dev/null +++ b/src/components/common/CommonMeta.astro @@ -0,0 +1,8 @@ +--- +import { getAsset } from '~/utils/permalinks'; +--- + +<meta charset="UTF-8" /> +<meta name="viewport" content="width=device-width, initial-scale=1.0" /> + +<link rel="sitemap" href={getAsset('/sitemap-index.xml')} /> diff --git a/src/components/common/Image.astro b/src/components/common/Image.astro new file mode 100644 index 0000000..d113b68 --- /dev/null +++ b/src/components/common/Image.astro @@ -0,0 +1,61 @@ +--- +import type { HTMLAttributes } from 'astro/types'; +import { findImage } from '~/utils/images'; +import { + getImagesOptimized, + astroAssetsOptimizer, + unpicOptimizer, + isUnpicCompatible, + type ImageProps, +} from '~/utils/images-optimization'; + +type Props = ImageProps; +type ImageType = { + src: string; + attributes: HTMLAttributes<'img'>; +}; + +const props = Astro.props; + +if (props.alt === undefined || props.alt === null) { + throw new Error(); +} + +if (typeof props.width === 'string') { + props.width = parseInt(props.width); +} + +if (typeof props.height === 'string') { + props.height = parseInt(props.height); +} + +if (!props.loading) { + props.loading = 'lazy'; +} + +if (!props.decoding) { + props.decoding = 'async'; +} + +const _image = await findImage(props.src); + +let image: ImageType | undefined = undefined; + +if ( + typeof _image === 'string' && + (_image.startsWith('http://') || _image.startsWith('https://')) && + isUnpicCompatible(_image) +) { + image = await getImagesOptimized(_image, props, unpicOptimizer); +} else if (_image) { + image = await getImagesOptimized(_image, props, astroAssetsOptimizer); +} +--- + +{ + !image ? ( + <Fragment /> + ) : ( + <img src={image.src} crossorigin="anonymous" referrerpolicy="no-referrer" {...image.attributes} /> + ) +} diff --git a/src/components/common/Metadata.astro b/src/components/common/Metadata.astro new file mode 100644 index 0000000..a4c573e --- /dev/null +++ b/src/components/common/Metadata.astro @@ -0,0 +1,68 @@ +--- +import merge from 'lodash.merge'; +import { AstroSeo } from '@astrolib/seo'; + +import type { Props as AstroSeoProps } from '@astrolib/seo'; + +import { SITE, METADATA, I18N } from 'astrowind:config'; +import type { MetaData } from '~/types'; +import { getCanonical } from '~/utils/permalinks'; + +import { adaptOpenGraphImages } from '~/utils/images'; + +export interface Props extends MetaData { + dontUseTitleTemplate?: boolean; +} + +const { + title, + ignoreTitleTemplate = false, + canonical = String(getCanonical(String(Astro.url.pathname))), + robots = {}, + description, + openGraph = {}, + twitter = {}, +} = Astro.props; + +const seoProps: AstroSeoProps = merge( + { + title: '', + titleTemplate: '%s', + canonical: canonical, + noindex: true, + nofollow: true, + description: undefined, + openGraph: { + url: canonical, + site_name: SITE?.name, + images: [], + locale: I18N?.language || 'en', + type: 'website', + }, + twitter: { + cardType: openGraph?.images?.length ? 'summary_large_image' : 'summary', + }, + }, + { + title: METADATA?.title?.default, + titleTemplate: METADATA?.title?.template, + noindex: typeof METADATA?.robots?.index !== 'undefined' ? !METADATA.robots.index : undefined, + nofollow: typeof METADATA?.robots?.follow !== 'undefined' ? !METADATA.robots.follow : undefined, + description: METADATA?.description, + openGraph: METADATA?.openGraph, + twitter: METADATA?.twitter, + }, + { + title: title, + titleTemplate: ignoreTitleTemplate ? '%s' : undefined, + canonical: canonical, + noindex: typeof robots?.index !== 'undefined' ? !robots.index : undefined, + nofollow: typeof robots?.follow !== 'undefined' ? !robots.follow : undefined, + description: description, + openGraph: { url: canonical, ...openGraph }, + twitter: twitter, + } +); +--- + +<AstroSeo {...{ ...seoProps, openGraph: await adaptOpenGraphImages(seoProps?.openGraph, Astro.site) }} /> diff --git a/src/components/common/SiteVerification.astro b/src/components/common/SiteVerification.astro new file mode 100644 index 0000000..000baad --- /dev/null +++ b/src/components/common/SiteVerification.astro @@ -0,0 +1,5 @@ +--- +import { SITE } from 'astrowind:config'; +--- + +{SITE.googleSiteVerificationId && <meta name="google-site-verification" content={SITE.googleSiteVerificationId} />} diff --git a/src/components/common/SocialShare.astro b/src/components/common/SocialShare.astro new file mode 100644 index 0000000..d035e8f --- /dev/null +++ b/src/components/common/SocialShare.astro @@ -0,0 +1,65 @@ +--- +import { Icon } from 'astro-icon/components'; + +export interface Props { + text: string; + url: string | URL; + class?: string; +} + +const { text, url, class: className = 'inline-block' } = Astro.props; +--- + +<div class={className}> + <span class="align-super font-bold text-slate-500 dark:text-slate-400">Share:</span> + <button + class="ml-2 rtl:ml-0 rtl:mr-2" + title="Twitter Share" + data-aw-social-share="twitter" + data-aw-url={url} + data-aw-text={text} + ><Icon + name="tabler:brand-x" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> + <button class="ml-2 rtl:ml-0 rtl:mr-2" title="Facebook Share" data-aw-social-share="facebook" data-aw-url={url} + ><Icon + name="tabler:brand-facebook" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> + <button + class="ml-2 rtl:ml-0 rtl:mr-2" + title="Linkedin Share" + data-aw-social-share="linkedin" + data-aw-url={url} + data-aw-text={text} + ><Icon + name="tabler:brand-linkedin" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> + <button + class="ml-2 rtl:ml-0 rtl:mr-2" + title="Whatsapp Share" + data-aw-social-share="whatsapp" + data-aw-url={url} + data-aw-text={text} + ><Icon + name="tabler:brand-whatsapp" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> + <button + class="ml-2 rtl:ml-0 rtl:mr-2" + title="Email Share" + data-aw-social-share="mail" + data-aw-url={url} + data-aw-text={text} + ><Icon + name="tabler:mail" + class="w-6 h-6 text-gray-400 dark:text-slate-500 hover:text-black dark:hover:text-slate-300" + /> + </button> +</div> diff --git a/src/components/common/SplitbeeAnalytics.astro b/src/components/common/SplitbeeAnalytics.astro new file mode 100644 index 0000000..66651db --- /dev/null +++ b/src/components/common/SplitbeeAnalytics.astro @@ -0,0 +1,6 @@ +--- +const { doNotTrack = true, noCookieMode = false, url = 'https://cdn.splitbee.io/sb.js' } = Astro.props; +--- + +<!-- Splitbee Analytics --> +<script is:inline data-respect-dnt={doNotTrack} data-no-cookie={noCookieMode} async src={url}></script> diff --git a/src/components/common/ToggleMenu.astro b/src/components/common/ToggleMenu.astro new file mode 100644 index 0000000..2d19b16 --- /dev/null +++ b/src/components/common/ToggleMenu.astro @@ -0,0 +1,29 @@ +--- +export interface Props { + label?: string; + class?: string; +} + +const { + label = 'Toggle Menu', + class: className = 'flex flex-col h-12 w-12 rounded justify-center items-center cursor-pointer group', +} = Astro.props; +--- + +<button type="button" class={className} aria-label={label} data-aw-toggle-menu> + <span class="sr-only">{label}</span> + <slot> + <span + aria-hidden="true" + class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:rotate-45 group-[.expanded]:translate-y-2.5" + ></span> + <span + aria-hidden="true" + class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:opacity-0" + ></span> + <span + aria-hidden="true" + class="h-0.5 w-6 my-1 rounded-full bg-black dark:bg-white transition ease transform duration-200 opacity-80 group-[.expanded]:-rotate-45 group-[.expanded]:-translate-y-2.5" + ></span> + </slot> +</button> diff --git a/src/components/common/ToggleTheme.astro b/src/components/common/ToggleTheme.astro new file mode 100644 index 0000000..8f3aafb --- /dev/null +++ b/src/components/common/ToggleTheme.astro @@ -0,0 +1,28 @@ +--- +import { Icon } from 'astro-icon/components'; + +import { UI } from 'astrowind:config'; + +export interface Props { + label?: string; + class?: string; + iconClass?: string; + iconName?: string; +} + +const { + label = 'Toggle between Dark and Light mode', + class: + className = 'text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center', + iconClass = 'w-6 h-6', + iconName = 'tabler:sun', +} = Astro.props; +--- + +{ + !(UI.theme && UI.theme.endsWith(':only')) && ( + <button type="button" class={className} aria-label={label} data-aw-toggle-color-scheme> + <Icon name={iconName} class={iconClass} /> + </button> + ) +} diff --git a/src/components/ui/Background.astro b/src/components/ui/Background.astro new file mode 100644 index 0000000..f220487 --- /dev/null +++ b/src/components/ui/Background.astro @@ -0,0 +1,11 @@ +--- +export interface Props { + isDark?: boolean; +} + +const { isDark = false } = Astro.props; +--- + +<div class:list={['absolute inset-0', { 'bg-section dark:bg-dark': isDark }]}> + <slot /> +</div> diff --git a/src/components/ui/Button.astro b/src/components/ui/Button.astro new file mode 100644 index 0000000..d3c2398 --- /dev/null +++ b/src/components/ui/Button.astro @@ -0,0 +1,40 @@ +--- +import { Icon } from 'astro-icon/components'; +import { twMerge } from 'tailwind-merge'; +import type { CallToAction as Props } from '~/types'; + +const { + variant = 'secondary', + target, + text = Astro.slots.render('default'), + icon = '', + class: className = '', + type, + ...rest +} = Astro.props; + +const variants = { + primary: 'btn-primary', + secondary: 'btn-secondary', + tertiary: 'btn btn-tertiary', + link: 'cursor-pointer hover:text-primary', +}; +--- + +{ + type === 'button' || type === 'submit' || type === 'reset' ? ( + <button type={type} class={twMerge(variants[variant] || '', className)} {...rest}> + <Fragment set:html={text} /> + {icon && <Icon name={icon} class="w-5 h-5 ml-1 -mr-1.5 rtl:mr-1 rtl:-ml-1.5 inline-block" />} + </button> + ) : ( + <a + class={twMerge(variants[variant] || '', className)} + {...(target ? { target: target, rel: 'noopener noreferrer' } : {})} + {...rest} + > + <Fragment set:html={text} /> + {icon && <Icon name={icon} class="w-5 h-5 ml-1 -mr-1.5 rtl:mr-1 rtl:-ml-1.5 inline-block" />} + </a> + ) +} diff --git a/src/components/ui/DListItem.astro b/src/components/ui/DListItem.astro new file mode 100644 index 0000000..36d4072 --- /dev/null +++ b/src/components/ui/DListItem.astro @@ -0,0 +1,22 @@ +--- +// component: DListItem +// +// Mimics the html 'dl' (description list) +// +// The 'dt' item is the item 'term' and is inserted into an 'h6' tag. +// Caller needs to style the 'h6' tag appropriately. +// +// You can put pretty much any content you want between the open and +// closing tags - it's simply contained in an enclosing div with a +// margin left. No need for 'dd' items. +// +const { dt } = Astro.props; +interface Props { + dt: string; +} + +const content: string = await Astro.slots.render('default'); +--- + +<h6 set:html={dt} /> +<div class="dd ml-8" set:html={content} /> diff --git a/src/components/ui/Form.astro b/src/components/ui/Form.astro new file mode 100644 index 0000000..276b39f --- /dev/null +++ b/src/components/ui/Form.astro @@ -0,0 +1,87 @@ +--- +import type { Form as Props } from '~/types'; +import Button from '~/components/ui/Button.astro'; + +const { inputs, textarea, disclaimer, button = 'Contact us', description = '' } = Astro.props; +--- + +<form> + { + inputs && + inputs.map( + ({ type = 'text', name, label = '', autocomplete = 'on', placeholder = '' }) => + name && ( + <div class="mb-6"> + {label && ( + <label for={name} class="block text-sm font-medium"> + {label} + </label> + )} + <input + type={type} + name={name} + id={name} + autocomplete={autocomplete} + placeholder={placeholder} + class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" + /> + </div> + ) + ) + } + + { + textarea && ( + <div> + <label for="textarea" class="block text-sm font-medium"> + {textarea.label} + </label> + <textarea + id="textarea" + name={textarea.name ? textarea.name : 'message'} + rows={textarea.rows ? textarea.rows : 4} + placeholder={textarea.placeholder} + class="py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" + /> + </div> + ) + } + + { + disclaimer && ( + <div class="mt-3 flex items-start"> + <div class="flex mt-0.5"> + <input + id="disclaimer" + name="disclaimer" + type="checkbox" + class="cursor-pointer mt-1 py-3 px-4 block w-full text-md rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900" + /> + </div> + <div class="ml-3"> + <label for="disclaimer" class="cursor-pointer select-none text-sm text-gray-600 dark:text-gray-400"> + {disclaimer.label} + </label> + </div> + </div> + ) + } + + { + button && ( + <div class="mt-10 grid"> + <Button variant="primary" type="submit"> + {button} + </Button> + </div> + ) + } + + { + description && ( + <div class="mt-3 text-center"> + <p class="text-sm text-gray-600 dark:text-gray-400">{description}</p> + </div> + ) + } +</form> diff --git a/src/components/ui/Headline.astro b/src/components/ui/Headline.astro new file mode 100644 index 0000000..6b906b0 --- /dev/null +++ b/src/components/ui/Headline.astro @@ -0,0 +1,35 @@ +--- +import type { Headline as Props } from '~/types'; +import { twMerge } from 'tailwind-merge'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + classes = {}, +} = Astro.props; + +const { + container: containerClass = 'max-w-3xl', + title: titleClass = 'text-3xl md:text-4xl ', + subtitle: subtitleClass = 'text-xl', +} = classes; +--- + +{ + (title || subtitle || tagline) && ( + <div class={twMerge('mb-8 md:mx-auto md:mb-12 text-center', containerClass)}> + {tagline && ( + <p class="text-base text-secondary dark:text-primary font-bold tracking-wide uppercase" set:html={tagline} /> + )} + {title && ( + <h2 + class={twMerge('font-bold leading-tighter tracking-tighter font-heading text-heading text-3xl', titleClass)} + set:html={title} + /> + )} + + {subtitle && <p class={twMerge('mt-4 text-muted', subtitleClass)} set:html={subtitle} />} + </div> + ) +} diff --git a/src/components/ui/ItemGrid.astro b/src/components/ui/ItemGrid.astro new file mode 100644 index 0000000..79931b9 --- /dev/null +++ b/src/components/ui/ItemGrid.astro @@ -0,0 +1,65 @@ +--- +import type { ItemGrid as Props } from '~/types'; +import { twMerge } from 'tailwind-merge'; +import Button from './Button.astro'; +import { Icon } from 'astro-icon/components'; + +const { items = [], columns, defaultIcon = '', classes = {} } = Astro.props; + +const { + container: containerClass = '', + panel: panelClass = '', + title: titleClass = '', + description: descriptionClass = '', + icon: defaultIconClass = 'text-primary', + action: actionClass = '', +} = classes; +--- + +{ + items && items.length > 0 && ( + <div + class={twMerge( + `grid mx-auto gap-8 md:gap-y-12 ${ + columns === 4 + ? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2' + : columns === 3 + ? 'lg:grid-cols-3 sm:grid-cols-2' + : columns === 2 + ? 'sm:grid-cols-2 ' + : '' + }`, + containerClass + )} + > + {items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => ( + <div class="intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"> + <div class={twMerge('flex flex-row max-w-md', panelClass, itemClasses?.panel)}> + <div class="flex justify-center"> + {(icon || defaultIcon) && ( + <Icon + name={icon || defaultIcon} + class={twMerge('w-7 h-7 mr-2 rtl:mr-0 rtl:ml-2', defaultIconClass, itemClasses?.icon)} + /> + )} + </div> + <div class="mt-0.5"> + {title && <h3 class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</h3>} + {description && ( + <p + class={twMerge(`${title ? 'mt-3' : ''} text-muted`, descriptionClass, itemClasses?.description)} + set:html={description} + /> + )} + {callToAction && ( + <div class={twMerge(`${title || description ? 'mt-3' : ''}`, actionClass, itemClasses?.actionClass)}> + <Button variant="link" {...callToAction} /> + </div> + )} + </div> + </div> + </div> + ))} + </div> + ) +} diff --git a/src/components/ui/ItemGrid2.astro b/src/components/ui/ItemGrid2.astro new file mode 100644 index 0000000..81faadf --- /dev/null +++ b/src/components/ui/ItemGrid2.astro @@ -0,0 +1,59 @@ +--- +import type { ItemGrid as Props } from '~/types'; +import { Icon } from 'astro-icon/components'; +import { twMerge } from 'tailwind-merge'; +import Button from './Button.astro'; + +const { items = [], columns, defaultIcon = '', classes = {} } = Astro.props; + +const { + container: containerClass = '', + // container: containerClass = "sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3", + panel: panelClass = '', + title: titleClass = '', + description: descriptionClass = '', + icon: defaultIconClass = 'text-primary', +} = classes; +--- + +{ + items && items.length > 0 && ( + <div + class={twMerge( + `grid gap-8 gap-x-12 sm:gap-y-8 ${ + columns === 4 + ? 'lg:grid-cols-4 md:grid-cols-3 sm:grid-cols-2' + : columns === 3 + ? 'lg:grid-cols-3 sm:grid-cols-2' + : columns === 2 + ? 'sm:grid-cols-2 ' + : '' + }`, + containerClass + )} + > + {items.map(({ title, description, icon, callToAction, classes: itemClasses = {} }) => ( + <div + class={twMerge( + 'relative flex flex-col intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade', + panelClass, + itemClasses?.panel + )} + > + {(icon || defaultIcon) && ( + <Icon name={icon || defaultIcon} class={twMerge('mb-2 w-10 h-10', defaultIconClass, itemClasses?.icon)} /> + )} + <div class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)}>{title}</div> + {description && ( + <p class={twMerge('text-muted mt-2', descriptionClass, itemClasses?.description)} set:html={description} /> + )} + {callToAction && ( + <div class="mt-2"> + <Button {...callToAction} /> + </div> + )} + </div> + ))} + </div> + ) +} diff --git a/src/components/ui/Timeline.astro b/src/components/ui/Timeline.astro new file mode 100644 index 0000000..b25c9de --- /dev/null +++ b/src/components/ui/Timeline.astro @@ -0,0 +1,60 @@ +--- +import { Icon } from 'astro-icon/components'; +import { twMerge } from 'tailwind-merge'; +import type { Item } from '~/types'; + +export interface Props { + items?: Array<Item>; + defaultIcon?: string; + classes?: Record<string, string>; +} + +const { items = [], classes = {}, defaultIcon } = Astro.props as Props; + +const { + container: containerClass = '', + panel: panelClass = '', + title: titleClass = '', + description: descriptionClass = '', + icon: defaultIconClass = 'text-primary dark:text-slate-200 border-primary dark:border-secondary', +} = classes; +--- + +{ + items && items.length > 0 && ( + <div class={containerClass}> + {items.map(({ title, description, icon, classes: itemClasses = {} }, index = 0) => ( + <div + class={twMerge( + 'flex intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade', + panelClass, + itemClasses?.panel + )} + > + <div class="flex flex-col items-center mr-4 rtl:mr-0 rtl:ml-4"> + <div> + <div class="flex items-center justify-center"> + {(icon || defaultIcon) && ( + <Icon + name={icon || defaultIcon} + class={twMerge('w-10 h-10 p-2 rounded-full border-2', defaultIconClass, itemClasses?.icon)} + /> + )} + </div> + </div> + {index !== items.length - 1 && <div class="w-px h-full bg-black/10 dark:bg-slate-400/50" />} + </div> + <div class={`pt-1 ${index !== items.length - 1 ? 'pb-8' : ''}`}> + {title && <p class={twMerge('text-xl font-bold', titleClass, itemClasses?.title)} set:html={title} />} + {description && ( + <p + class={twMerge('text-muted mt-2', descriptionClass, itemClasses?.description)} + set:html={description} + /> + )} + </div> + </div> + ))} + </div> + ) +} diff --git a/src/components/ui/WidgetWrapper.astro b/src/components/ui/WidgetWrapper.astro new file mode 100644 index 0000000..c42c751 --- /dev/null +++ b/src/components/ui/WidgetWrapper.astro @@ -0,0 +1,34 @@ +--- +import type { HTMLTag } from 'astro/types'; +import type { Widget } from '~/types'; +import { twMerge } from 'tailwind-merge'; +import Background from './Background.astro'; + +export interface Props extends Widget { + containerClass?: string; + ['as']?: HTMLTag; +} + +const { id, isDark = false, containerClass = '', bg, as = 'section' } = Astro.props; + +const WrapperTag = as; +--- + +<WrapperTag class="relative not-prose scroll-mt-[72px]" {...id ? { id } : {}}> + <div class="absolute inset-0 pointer-events-none -z-[1]" aria-hidden="true"> + <slot name="bg"> + {bg ? <Fragment set:html={bg} /> : <Background isDark={isDark} />} + </slot> + </div> + <div + class:list={[ + twMerge( + 'relative mx-auto max-w-7xl px-4 md:px-6 py-12 md:py-16 lg:py-20 text-default intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade', + containerClass + ), + { dark: isDark }, + ]} + > + <slot /> + </div> +</WrapperTag> diff --git a/src/components/widgets/Announcement.astro b/src/components/widgets/Announcement.astro new file mode 100644 index 0000000..8e4bf78 --- /dev/null +++ b/src/components/widgets/Announcement.astro @@ -0,0 +1,16 @@ +--- + +--- + +<div + class="dark text-muted text-sm bg-black dark:bg-transparent dark:border-b dark:border-slate-800 dark:text-slate-400 hidden md:flex gap-1 overflow-hidden px-3 py-2 relative text-ellipsis whitespace-nowrap" +> + <span + class="dark:bg-slate-700 bg-white/40 dark:text-slate-300 font-semibold px-1 py-0.5 text-xs mr-0.5 rtl:mr-0 rtl:ml-0.5 inline-block" + >NEW</span + > + <a href="#contact" class="text-muted hover:underline dark:text-slate-400 font-medium" + >Skontaktuj się z nami - Darmowa wycena! »</a + > + +</div> diff --git a/src/components/widgets/BlogHighlightedPosts.astro b/src/components/widgets/BlogHighlightedPosts.astro new file mode 100644 index 0000000..75f35a9 --- /dev/null +++ b/src/components/widgets/BlogHighlightedPosts.astro @@ -0,0 +1,64 @@ +--- +import { APP_BLOG } from 'astrowind:config'; + +import Grid from '~/components/blog/Grid.astro'; + +import { getBlogPermalink } from '~/utils/permalinks'; +import { findPostsByIds } from '~/utils/blog'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { Widget } from '~/types'; + +export interface Props extends Widget { + title?: string; + linkText?: string; + linkUrl?: string | URL; + information?: string; + postIds: string[]; +} + +const { + title = await Astro.slots.render('title'), + linkText = 'View all posts', + linkUrl = getBlogPermalink(), + information = await Astro.slots.render('information'), + postIds = [], + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; + +const posts = APP_BLOG.isEnabled ? await findPostsByIds(postIds) : []; +--- + +{ + APP_BLOG.isEnabled ? ( + <WidgetWrapper id={id} isDark={isDark} containerClass={classes?.container as string} bg={bg}> + <div class="flex flex-col lg:justify-between lg:flex-row mb-8"> + {title && ( + <div class="md:max-w-sm"> + <h2 + class="text-3xl font-bold tracking-tight sm:text-4xl sm:leading-none group font-heading mb-2" + set:html={title} + /> + {APP_BLOG.list.isEnabled && linkText && linkUrl && ( + <a + class="text-muted dark:text-slate-400 hover:text-primary transition ease-in duration-200 block mb-6 lg:mb-0" + href={linkUrl} + > + {linkText} » + </a> + )} + </div> + )} + + {information && <p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md" set:html={information} />} + </div> + + <Grid posts={posts} /> + </WidgetWrapper> + ) : ( + <Fragment /> + ) +} diff --git a/src/components/widgets/BlogLatestPosts.astro b/src/components/widgets/BlogLatestPosts.astro new file mode 100644 index 0000000..28f66d4 --- /dev/null +++ b/src/components/widgets/BlogLatestPosts.astro @@ -0,0 +1,63 @@ +--- +import { APP_BLOG } from 'astrowind:config'; + +import Grid from '~/components/blog/Grid.astro'; + +import { getBlogPermalink } from '~/utils/permalinks'; +import { findLatestPosts } from '~/utils/blog'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { Widget } from '~/types'; +import Button from '~/components/ui/Button.astro'; + +export interface Props extends Widget { + title?: string; + linkText?: string; + linkUrl?: string | URL; + information?: string; + count?: number; +} + +const { + title = await Astro.slots.render('title'), + linkText = 'View all posts', + linkUrl = getBlogPermalink(), + information = await Astro.slots.render('information'), + count = 4, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; + +const posts = APP_BLOG.isEnabled ? await findLatestPosts({ count }) : []; +--- + +{ + APP_BLOG.isEnabled ? ( + <WidgetWrapper id={id} isDark={isDark} containerClass={classes?.container as string} bg={bg}> + <div class="flex flex-col lg:justify-between lg:flex-row mb-8"> + {title && ( + <div class="md:max-w-sm"> + <h2 + class="text-3xl font-bold tracking-tight sm:text-4xl sm:leading-none group font-heading mb-2" + set:html={title} + /> + {APP_BLOG.list.isEnabled && linkText && linkUrl && ( + <Button variant="link" href={linkUrl}> + {' '} + {linkText} » + </Button> + )} + </div> + )} + + {information && <p class="text-muted dark:text-slate-400 lg:text-sm lg:max-w-md" set:html={information} />} + </div> + + <Grid posts={posts} /> + </WidgetWrapper> + ) : ( + <Fragment /> + ) +} diff --git a/src/components/widgets/Brands.astro b/src/components/widgets/Brands.astro new file mode 100644 index 0000000..7e42ae1 --- /dev/null +++ b/src/components/widgets/Brands.astro @@ -0,0 +1,38 @@ +--- +import { Icon } from 'astro-icon/components'; +import type { Brands as Props } from '~/types'; + +import Image from '~/components/common/Image.astro'; +import Headline from '~/components/ui/Headline.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +const { + title = '', + subtitle = '', + tagline = '', + icons = [], + images = [], + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <Headline title={title} subtitle={subtitle} tagline={tagline} /> + + <div class="flex flex-wrap justify-center gap-x-6 sm:gap-x-12 lg:gap-x-24"> + {icons && icons.map((icon) => <Icon name={icon} class="py-3 lg:py-5 w-12 h-auto mx-auto sm:mx-0 text-gray-500" />)} + { + images && + images.map( + (image) => + image.src && ( + <div class="flex justify-center col-span-1 my-2 lg:my-4 py-1 px-3 rounded-md dark:bg-gray-200"> + <Image src={image.src} alt={image.alt || ''} class="max-h-12" width={120} height={48} layout="fixed" /> + </div> + ) + ) + } + </div> +</WidgetWrapper> diff --git a/src/components/widgets/CallToAction.astro b/src/components/widgets/CallToAction.astro new file mode 100644 index 0000000..f51aa91 --- /dev/null +++ b/src/components/widgets/CallToAction.astro @@ -0,0 +1,58 @@ +--- +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { CallToAction, Widget } from '~/types'; +import Headline from '~/components/ui/Headline.astro'; +import Button from '~/components/ui/Button.astro'; + +interface Props extends Widget { + title?: string; + subtitle?: string; + tagline?: string; + callToAction?: CallToAction; + actions?: string | CallToAction[]; +} + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + actions = await Astro.slots.render('actions'), + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <div + class="max-w-3xl mx-auto text-center p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-600" + > + <Headline + title={title} + subtitle={subtitle} + tagline={tagline} + classes={{ + container: 'mb-0 md:mb-0', + title: 'text-4xl md:text-4xl font-bold tracking-tighter mb-4 font-heading', + subtitle: 'text-xl text-muted dark:text-slate-400', + }} + /> + { + actions && ( + <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 mt-6"> + {Array.isArray(actions) ? ( + actions.map((action) => ( + <div class="flex w-full sm:w-auto"> + <Button {...(action || {})} class="w-full sm:mb-0" /> + </div> + )) + ) : ( + <Fragment set:html={actions} /> + )} + </div> + ) + } + </div> +</WidgetWrapper> diff --git a/src/components/widgets/CallToActionImage.astro b/src/components/widgets/CallToActionImage.astro new file mode 100644 index 0000000..df13145 --- /dev/null +++ b/src/components/widgets/CallToActionImage.astro @@ -0,0 +1,73 @@ +--- +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { Widget } from '~/types'; +import Headline from '~/components/ui/Headline.astro'; +import Image from '~/components/common/Image.astro'; + +interface Props extends Widget { + title?: string; + subtitle?: string; + tagline?: string; + image?: { + src: string; + alt: string; + href?: string; + target?: string; + }; +} + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + image, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <div + class="max-w-3xl mx-auto text-center p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-600" + > + <Headline + title={title} + subtitle={subtitle} + tagline={tagline} + classes={{ + container: 'mb-0 md:mb-0', + title: 'text-4xl md:text-4xl font-bold tracking-tighter mb-4 font-heading', + subtitle: 'text-xl text-muted dark:text-slate-400', + }} + /> + { + image && ( + <div class="flex justify-center mt-6 px-4"> + {image.href ? ( + <a + href={image.href} + target={image.target || '_self'} + rel={image.target === '_blank' ? 'noopener noreferrer' : ''} + class="inline-block hover:opacity-80 transition-opacity duration-200" + > + <Image + src={image.src} + alt={image.alt} + class="max-h-20 h-auto w-auto max-w-full object-contain" + /> + </a> + ) : ( + <Image + src={image.src} + alt={image.alt} + class="max-h-20 h-auto w-auto max-w-full object-contain" + /> + )} + </div> + ) + } + </div> +</WidgetWrapper>
\ No newline at end of file diff --git a/src/components/widgets/Contact.astro b/src/components/widgets/Contact.astro new file mode 100644 index 0000000..122f4b0 --- /dev/null +++ b/src/components/widgets/Contact.astro @@ -0,0 +1,40 @@ +--- +import FormContainer from '~/components/ui/Form.astro'; +import Headline from '~/components/ui/Headline.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { Contact as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + inputs, + textarea, + disclaimer, + button, + description, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <Headline title={title} subtitle={subtitle} tagline={tagline} /> + + { + inputs && ( + <div class="flex flex-col max-w-xl mx-auto rounded-lg backdrop-blur border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 shadow p-4 sm:p-6 lg:p-8 w-full"> + <FormContainer + inputs={inputs} + textarea={textarea} + disclaimer={disclaimer} + button={button} + description={description} + /> + </div> + ) + } +</WidgetWrapper> diff --git a/src/components/widgets/Content.astro b/src/components/widgets/Content.astro new file mode 100644 index 0000000..694a198 --- /dev/null +++ b/src/components/widgets/Content.astro @@ -0,0 +1,94 @@ +--- +import type { Content as Props } from '~/types'; +import Headline from '~/components/ui/Headline.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Image from '~/components/common/Image.astro'; +import Button from '~/components/ui/Button.astro'; +import ItemGrid from '~/components/ui/ItemGrid.astro'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + content = await Astro.slots.render('content'), + callToAction, + items = [], + columns, + image = await Astro.slots.render('image'), + isReversed = false, + isAfterContent = false, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper + id={id} + isDark={isDark} + containerClass={`max-w-7xl mx-auto ${isAfterContent ? 'pt-0 md:pt-0 lg:pt-0' : ''} ${classes?.container ?? ''}`} + bg={bg} +> + <Headline + title={title} + subtitle={subtitle} + tagline={tagline} + classes={{ + container: 'max-w-xl sm:mx-auto lg:max-w-2xl', + title: 'text-4xl md:text-5xl font-bold tracking-tighter mb-4 font-heading', + subtitle: 'max-w-3xl mx-auto sm:text-center text-xl text-muted dark:text-slate-400', + }} + /> + <div class="mx-auto max-w-7xl p-4 md:px-8"> + <div class={`md:flex ${isReversed ? 'md:flex-row-reverse' : ''} md:gap-16`}> + <div class="md:basis-1/2 self-center"> + {content && <div class="mb-12 text-lg dark:text-slate-400" set:html={content} />} + + { + callToAction && ( + <div class="mt-[-40px] mb-8 text-primary"> + <Button variant="link" {...callToAction} /> + </div> + ) + } + + <ItemGrid + items={items} + columns={columns} + defaultIcon="tabler:check" + classes={{ + container: `gap-y-4 md:gap-y-8`, + panel: 'max-w-none', + title: 'text-lg font-medium leading-6 dark:text-white ml-2 rtl:ml-0 rtl:mr-2', + description: 'text-muted dark:text-slate-400 ml-2 rtl:ml-0 rtl:mr-2', + icon: 'flex h-7 w-7 items-center justify-center rounded-full bg-green-600 dark:bg-green-700 text-gray-50 p-1', + action: 'text-lg font-medium leading-6 dark:text-white ml-2 rtl:ml-0 rtl:mr-2', + }} + /> + </div> + <div aria-hidden="true" class="mt-10 md:mt-0 md:basis-1/2"> + { + image && ( + <div class="relative m-auto max-w-4xl"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="mx-auto w-full rounded-lg bg-gray-500 shadow-lg" + width={500} + height={500} + widths={[400, 768]} + sizes="(max-width: 768px) 100vw, 432px" + layout="responsive" + {...image} + /> + )} + </div> + ) + } + </div> + </div> + </div> +</WidgetWrapper> diff --git a/src/components/widgets/FAQs.astro b/src/components/widgets/FAQs.astro new file mode 100644 index 0000000..cba9762 --- /dev/null +++ b/src/components/widgets/FAQs.astro @@ -0,0 +1,33 @@ +--- +import Headline from '~/components/ui/Headline.astro'; +import ItemGrid from '~/components/ui/ItemGrid.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { Faqs as Props } from '~/types'; + +const { + title = '', + subtitle = '', + tagline = '', + items = [], + columns = 2, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <Headline title={title} subtitle={subtitle} tagline={tagline} /> + <ItemGrid + items={items} + columns={columns} + defaultIcon="tabler:chevron-right" + classes={{ + container: `${columns === 1 ? 'max-w-4xl' : ''} gap-y-8 md:gap-y-12`, + panel: 'max-w-none', + icon: 'flex-shrink-0 mt-1 w-6 h-6 text-primary', + }} + /> +</WidgetWrapper> diff --git a/src/components/widgets/Features.astro b/src/components/widgets/Features.astro new file mode 100644 index 0000000..8f42b62 --- /dev/null +++ b/src/components/widgets/Features.astro @@ -0,0 +1,36 @@ +--- +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import ItemGrid from '~/components/ui/ItemGrid.astro'; +import Headline from '~/components/ui/Headline.astro'; +import type { Features as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + items = [], + columns = 2, + + defaultIcon, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-5xl ${classes?.container ?? ''}`} bg={bg}> + <Headline title={title} subtitle={subtitle} tagline={tagline} classes={classes?.headline as Record<string, string>} /> + <ItemGrid + items={items} + columns={columns} + defaultIcon={defaultIcon} + classes={{ + container: '', + title: 'md:text-[1.3rem]', + icon: 'text-white bg-primary rounded-full w-10 h-10 p-2 md:w-12 md:h-12 md:p-3 mr-4 rtl:ml-4 rtl:mr-0', + ...((classes?.items as Record<string, never>) ?? {}), + }} + /> +</WidgetWrapper> diff --git a/src/components/widgets/Features2.astro b/src/components/widgets/Features2.astro new file mode 100644 index 0000000..282337e --- /dev/null +++ b/src/components/widgets/Features2.astro @@ -0,0 +1,38 @@ +--- +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Headline from '~/components/ui/Headline.astro'; +import ItemGrid2 from '~/components/ui/ItemGrid2.astro'; +import type { Features as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + items = [], + columns = 3, + defaultIcon, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <Headline title={title} subtitle={subtitle} tagline={tagline} classes={classes?.headline as Record<string, string>} /> + <ItemGrid2 + items={items} + columns={columns} + defaultIcon={defaultIcon} + classes={{ + container: 'gap-4 md:gap-6', + panel: + 'rounded-lg shadow-[0_4px_30px_rgba(0,0,0,0.1)] dark:shadow-[0_4px_30px_rgba(0,0,0,0.1)] backdrop-blur border border-[#ffffff29] bg-white dark:bg-slate-900 p-6', + // panel: + // "text-center bg-page items-center md:text-left rtl:md:text-right md:items-start p-6 p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-800", + icon: 'w-12 h-12 mb-6 text-primary', + ...((classes?.items as Record<string, never>) ?? {}), + }} + /> +</WidgetWrapper> diff --git a/src/components/widgets/Features3.astro b/src/components/widgets/Features3.astro new file mode 100644 index 0000000..62ab475 --- /dev/null +++ b/src/components/widgets/Features3.astro @@ -0,0 +1,70 @@ +--- +import Headline from '~/components/ui/Headline.astro'; +import ItemGrid from '~/components/ui/ItemGrid.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Image from '~/components/common/Image.astro'; +import type { Features as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + image, + items = [], + columns, + defaultIcon, + isBeforeContent, + isAfterContent, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper + id={id} + isDark={isDark} + containerClass={`${isBeforeContent ? 'md:pb-8 lg:pb-12' : ''} ${isAfterContent ? 'pt-0 md:pt-0 lg:pt-0' : ''} ${ + classes?.container ?? '' + }`} + bg={bg} +> + <Headline title={title} subtitle={subtitle} tagline={tagline} classes={classes?.headline as Record<string, string>} /> + + <div aria-hidden="true" class="aspect-w-16 aspect-h-7"> + { + image && ( + <div class="w-full h-80 object-cover rounded-xl mx-auto bg-gray-500 shadow-lg"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="w-full h-80 object-cover rounded-xl mx-auto bg-gray-500 shadow-lg" + width="auto" + height={320} + widths={[400, 768]} + layout="fullWidth" + {...image} + /> + )} + </div> + ) + } + </div> + + <ItemGrid + items={items} + columns={columns} + defaultIcon={defaultIcon} + classes={{ + container: 'mt-12', + panel: 'max-w-full sm:max-w-md', + title: 'text-lg font-semibold', + description: 'mt-0.5', + icon: 'flex-shrink-0 mt-1 text-primary w-6 h-6', + ...((classes?.items as object) ?? {}), + }} + /> +</WidgetWrapper> diff --git a/src/components/widgets/Footer.astro b/src/components/widgets/Footer.astro new file mode 100644 index 0000000..70cac54 --- /dev/null +++ b/src/components/widgets/Footer.astro @@ -0,0 +1,61 @@ +--- +import { Icon } from 'astro-icon/components'; +import { SITE } from 'astrowind:config'; +import { getHomePermalink } from '~/utils/permalinks'; + +interface Link { + text?: string; + href?: string; + ariaLabel?: string; + icon?: string; +} + +interface Links { + title?: string; + links: Array<Link>; +} + +export interface Props { + links: Array<Links>; + secondaryLinks: Array<Link>; + socialLinks: Array<Link>; + footNote?: string; + theme?: string; +} + +const { socialLinks = [], secondaryLinks = [], links = [], footNote = '', theme = 'light' } = Astro.props; +--- + +<footer class:list={[{ dark: theme === 'dark' }, 'relative border-t border-gray-200 dark:border-slate-800 not-prose']}> + <div class="dark:bg-dark absolute inset-0 pointer-events-none" aria-hidden="true"></div> + <div + class="relative max-w-7xl mx-auto px-4 sm:px-6 dark:text-slate-300 intersect-once intersect-quarter intercept-no-queue motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + > + <div class="md:flex md:items-center md:justify-between py-6 md:py-8"> + { + socialLinks?.length ? ( + <ul class="flex mb-4 md:order-1 -ml-2 md:ml-4 md:mb-0 rtl:ml-0 rtl:-mr-2 rtl:md:ml-0 rtl:md:mr-4"> + {socialLinks.map(({ ariaLabel, href, text, icon }) => ( + <li> + <a + class="text-muted dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5 inline-flex items-center" + aria-label={ariaLabel} + href={href} + > + {icon && <Icon name={icon} class="w-5 h-5" />} + <Fragment set:html={text} /> + </a> + </li> + ))} + </ul> + ) : ( + '' + ) + } + + <div class="text-sm mr-4 dark:text-muted"> + <Fragment set:html={footNote} /> + </div> + </div> + </div> +</footer> diff --git a/src/components/widgets/Header.astro b/src/components/widgets/Header.astro new file mode 100644 index 0000000..0064a30 --- /dev/null +++ b/src/components/widgets/Header.astro @@ -0,0 +1,14 @@ +--- +import { Icon } from 'astro-icon/components'; +--- + +<header class="bg-black text-white py-2 px-4"> + <div class="max-w-7xl mx-auto flex items-center justify-center"> + <div class="flex items-center space-x-2"> + <Icon name="tabler:phone" class="w-4 h-4" /> + <a href="tel:+48790209770" class="text-sm font-medium hover:text-gray-300 transition-colors"> + +48 790-209-770 + </a> + </div> + </div> +</header> diff --git a/src/components/widgets/Hero.astro b/src/components/widgets/Hero.astro new file mode 100644 index 0000000..cbd1154 --- /dev/null +++ b/src/components/widgets/Hero.astro @@ -0,0 +1,99 @@ +--- +import Image from '~/components/common/Image.astro'; +import Button from '~/components/ui/Button.astro'; + +import type { Hero as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + + content = await Astro.slots.render('content'), + actions = await Astro.slots.render('actions'), + image = await Astro.slots.render('image'), + + id, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}> + <div class="absolute inset-0 pointer-events-none" aria-hidden="true"> + <slot name="bg"> + {bg ? <Fragment set:html={bg} /> : null} + </slot> + </div> + <div class="relative max-w-7xl mx-auto px-4 sm:px-6"> + <div class="pt-0 md:pt-[76px] pointer-events-none"></div> + <div class="py-12 md:py-20"> + <div class="text-center pb-10 md:pb-16 max-w-5xl mx-auto"> + { + tagline && ( + <p + class="text-base text-secondary dark:text-primary font-bold tracking-wide uppercase intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={tagline} + /> + ) + } + { + title && ( + <h1 + class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={title} + /> + ) + } + <div class="max-w-3xl mx-auto"> + { + subtitle && ( + <p + class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={subtitle} + /> + ) + } + { + actions && ( + <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade"> + {Array.isArray(actions) ? ( + actions.map((action) => ( + <div class="flex w-full sm:w-auto"> + <Button {...(action || {})} class="w-full sm:mb-0" /> + </div> + )) + ) : ( + <Fragment set:html={actions} /> + )} + </div> + ) + } + </div> + {content && <Fragment set:html={content} />} + </div> + <div + class="intersect-once intercept-no-queue intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + > + { + image && ( + <div class="relative m-auto max-w-5xl"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="mx-auto rounded-md w-full" + widths={[400, 768, 1024, 2040]} + sizes="(max-width: 767px) 400px, (max-width: 1023px) 768px, (max-width: 2039px) 1024px, 2040px" + loading="eager" + width={1024} + height={576} + {...image} + /> + )} + </div> + ) + } + </div> + </div> + </div> +</section> diff --git a/src/components/widgets/Hero2.astro b/src/components/widgets/Hero2.astro new file mode 100644 index 0000000..e92870d --- /dev/null +++ b/src/components/widgets/Hero2.astro @@ -0,0 +1,99 @@ +--- +import Image from '~/components/common/Image.astro'; +import Button from '~/components/ui/Button.astro'; + +import type { Hero as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + + content = await Astro.slots.render('content'), + actions = await Astro.slots.render('actions'), + image = await Astro.slots.render('image'), + + id, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<section class="relative md:-mt-[76px] not-prose" {...id ? { id } : {}}> + <div class="absolute inset-0 pointer-events-none" aria-hidden="true"> + <slot name="bg"> + {bg ? <Fragment set:html={bg} /> : null} + </slot> + </div> + <div class="relative max-w-7xl mx-auto px-4 sm:px-6"> + <div class="pt-0 md:pt-[76px] pointer-events-none"></div> + <div class="py-12 md:py-20 lg:py-0 lg:flex lg:items-center lg:h-screen lg:gap-8"> + <div class="basis-1/2 text-center lg:text-left pb-10 md:pb-16 mx-auto"> + { + tagline && ( + <p + class="text-base text-secondary dark:text-primary font-bold tracking-wide uppercase intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter" + set:html={tagline} + /> + ) + } + { + title && ( + <h1 + class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter" + set:html={title} + /> + ) + } + <div class="max-w-3xl mx-auto lg:max-w-none"> + { + subtitle && ( + <p + class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter" + set:html={subtitle} + /> + ) + } + + { + actions && ( + <div class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 lg:justify-start lg:m-0 lg:max-w-7xl intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"> + {Array.isArray(actions) ? ( + actions.map((action) => ( + <div class="flex w-full sm:w-auto"> + <Button {...(action || {})} class="w-full sm:mb-0" /> + </div> + )) + ) : ( + <Fragment set:html={actions} /> + )} + </div> + ) + } + </div> + {content && <Fragment set:html={content} />} + </div> + <div class="basis-1/2"> + { + image && ( + <div class="relative m-auto max-w-5xl intersect-once intercept-no-queue motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="mx-auto rounded-md w-full" + widths={[400, 768, 1024, 2040]} + sizes="(max-width: 767px) 400px, (max-width: 1023px) 768px, (max-width: 2039px) 1024px, 2040px" + loading="eager" + width={1200} + height={800} + aspectRatio="3:2" + {...image} + /> + )} + </div> + ) + } + </div> + </div> + </div> +</section> diff --git a/src/components/widgets/HeroText.astro b/src/components/widgets/HeroText.astro new file mode 100644 index 0000000..be2a1b6 --- /dev/null +++ b/src/components/widgets/HeroText.astro @@ -0,0 +1,86 @@ +--- +import type { CallToAction } from '~/types'; +import Button from '~/components/ui/Button.astro'; + +export interface Props { + title?: string; + subtitle?: string; + tagline?: string; + content?: string; + callToAction?: string | CallToAction; + callToAction2?: string | CallToAction; +} + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + content = await Astro.slots.render('content'), + callToAction = await Astro.slots.render('callToAction'), + callToAction2 = await Astro.slots.render('callToAction2'), +} = Astro.props; +--- + +<section class="relative md:-mt-[76px] not-prose"> + <div class="absolute inset-0 pointer-events-none" aria-hidden="true"></div> + <div class="relative max-w-7xl mx-auto px-4 sm:px-6"> + <div class="pt-0 md:pt-[76px] pointer-events-none"></div> + <div class="py-12 md:py-20 pb-8 md:pb-8"> + <div class="text-center max-w-5xl mx-auto"> + { + tagline && ( + <p + class="text-base text-secondary dark:text-primary font-bold tracking-wide uppercase intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={tagline} + /> + ) + } + { + title && ( + <h1 + class="text-5xl md:text-6xl font-bold leading-tighter tracking-tighter mb-4 font-heading dark:text-gray-200 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={title} + /> + ) + } + <div class="max-w-3xl mx-auto"> + { + subtitle && ( + <p + class="text-xl text-muted mb-6 dark:text-slate-300 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + set:html={subtitle} + /> + ) + } + <div + class="max-w-xs sm:max-w-md m-auto flex flex-nowrap flex-col sm:flex-row sm:justify-center gap-4 intersect-once intersect-quarter motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade" + > + { + callToAction && ( + <div class="flex w-full sm:w-auto"> + {typeof callToAction === 'string' ? ( + <Fragment set:html={callToAction} /> + ) : ( + <Button variant="primary" {...callToAction} /> + )} + </div> + ) + } + { + callToAction2 && ( + <div class="flex w-full sm:w-auto"> + {typeof callToAction2 === 'string' ? ( + <Fragment set:html={callToAction2} /> + ) : ( + <Button {...callToAction2} /> + )} + </div> + ) + } + </div> + </div> + {content && <Fragment set:html={content} />} + </div> + </div> + </div> +</section> diff --git a/src/components/widgets/Note.astro b/src/components/widgets/Note.astro new file mode 100644 index 0000000..3f43881 --- /dev/null +++ b/src/components/widgets/Note.astro @@ -0,0 +1,23 @@ +--- +import { Icon } from 'astro-icon/components'; + +export interface Props { + icon?: string; + title?: string; + description?: string; +} + +const { + icon = 'tabler:info-square', + title = await Astro.slots.render('title'), + description = await Astro.slots.render('description'), +} = Astro.props; +--- + +<section class="bg-section dark:bg-slate-800 not-prose"> + <div class="max-w-6xl mx-auto px-4 sm:px-6 py-4 text-md text-center font-medium"> + <Icon name={icon} class="w-5 h-5 inline-block align-text-bottom font-bold" /> + <span class="font-bold" set:html={title} /> + <Fragment set:html={description} /> + </div> +</section> diff --git a/src/components/widgets/Pricing.astro b/src/components/widgets/Pricing.astro new file mode 100644 index 0000000..3f20b74 --- /dev/null +++ b/src/components/widgets/Pricing.astro @@ -0,0 +1,83 @@ +--- +import { Icon } from 'astro-icon/components'; +import Button from '~/components/ui/Button.astro'; +import Headline from '~/components/ui/Headline.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import type { Pricing as Props } from '~/types'; + +const { + title = '', + subtitle = '', + tagline = '', + prices = [], + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <Headline title={title} subtitle={subtitle} tagline={tagline} /> + <div class="flex items-stretch justify-center"> + <div class="grid grid-cols-3 gap-4 dark:text-white sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3"> + { + prices && + prices.map(({ title, subtitle, price, period, items, callToAction, hasRibbon = false, ribbonTitle }) => ( + <div class="col-span-3 mx-auto flex w-full sm:col-span-1 md:col-span-1 lg:col-span-1 xl:col-span-1 intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"> + {price && period && ( + <div class="rounded-lg backdrop-blur border border-gray-200 dark:border-gray-700 bg-white dark:bg-slate-900 shadow px-6 py-8 flex w-full max-w-sm flex-col justify-between text-center"> + {hasRibbon && ribbonTitle && ( + <div class="absolute right-[-5px] 2xl:right-[-8px] rtl:right-auto rtl:left-[-8px] rtl:2xl:left-[-10px] top-[-5px] 2xl:top-[-10px] z-[1] h-[100px] w-[100px] overflow-hidden text-right"> + <span class="absolute top-[19px] right-[-21px] rtl:right-auto rtl:left-[-21px] block w-full rotate-45 rtl:-rotate-45 bg-green-700 text-center text-[10px] font-bold uppercase leading-5 text-white shadow-[0_3px_10px_-5px_rgba(0,0,0,0.3)] before:absolute before:left-0 before:top-full before:z-[-1] before:border-[3px] before:border-r-transparent before:border-b-transparent before:border-l-green-800 before:border-t-green-800 before:content-[''] after:absolute after:right-0 after:top-full after:z-[-1] after:border-[3px] after:border-l-transparent after:border-b-transparent after:border-r-green-800 after:border-t-green-800 after:content-['']"> + {ribbonTitle} + </span> + </div> + )} + <div class="px-2 py-0"> + {title && ( + <h3 class="text-center text-xl font-semibold uppercase leading-6 tracking-wider mb-2">{title}</h3> + )} + {subtitle && <p class="font-light sm:text-lg text-gray-600 dark:text-slate-400">{subtitle}</p>} + <div class="my-8"> + <div class="flex items-center justify-center text-center mb-1"> + <span class="text-5xl">$</span> + <span class="text-6xl font-extrabold">{price}</span> + </div> + <span class="text-base leading-6 lowercase text-gray-600 dark:text-slate-400">{period}</span> + </div> + {items && ( + <ul class="my-8 md:my-10 space-y-2 text-left"> + {items.map( + ({ description, icon }) => + description && ( + <li class="mb-1.5 flex items-start space-x-3 leading-7"> + <div class="rounded-full bg-primary mt-1"> + <Icon name={icon ? icon : 'tabler:check'} class="w-5 h-5 font-bold p-1 text-white" /> + </div> + <span>{description}</span> + </li> + ) + )} + </ul> + )} + </div> + {callToAction && ( + <div class={`flex justify-center`}> + {typeof callToAction === 'string' ? ( + <Fragment set:html={callToAction} /> + ) : ( + callToAction && + callToAction.href && <Button {...(hasRibbon ? { variant: 'primary' } : {})} {...callToAction} /> + )} + </div> + )} + </div> + )} + </div> + )) + } + </div> + </div> +</WidgetWrapper> diff --git a/src/components/widgets/Stats.astro b/src/components/widgets/Stats.astro new file mode 100644 index 0000000..bf76ea0 --- /dev/null +++ b/src/components/widgets/Stats.astro @@ -0,0 +1,46 @@ +--- +import type { Stats as Props } from '~/types'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Headline from '~/components/ui/Headline.astro'; +import { Icon } from 'astro-icon/components'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + stats = [], + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <Headline title={title} subtitle={subtitle} tagline={tagline} /> + <div class="flex flex-wrap justify-center -m-4 text-center"> + { + stats && + stats.map(({ amount, title, icon }) => ( + <div class="p-4 md:w-1/4 sm:w-1/2 w-full min-w-[220px] text-center md:border-r md:last:border-none dark:md:border-slate-500 intersect-once motion-safe:md:opacity-0 motion-safe:md:intersect:animate-fade intersect-quarter"> + {icon && ( + <div class="flex items-center justify-center mx-auto mb-4 text-primary"> + <Icon name={icon} class="w-10 h-10" /> + </div> + )} + {amount && ( + <div class="font-heading text-primary text-[2.6rem] font-bold dark:text-white lg:text-5xl xl:text-6xl"> + {amount} + </div> + )} + {title && ( + <div class="text-sm font-medium uppercase tracking-widest text-gray-800 dark:text-slate-400 lg:text-base"> + {title} + </div> + )} + </div> + )) + } + </div> +</WidgetWrapper> diff --git a/src/components/widgets/Steps.astro b/src/components/widgets/Steps.astro new file mode 100644 index 0000000..3c65bf6 --- /dev/null +++ b/src/components/widgets/Steps.astro @@ -0,0 +1,59 @@ +--- +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Timeline from '~/components/ui/Timeline.astro'; +import Headline from '~/components/ui/Headline.astro'; +import Image from '~/components/common/Image.astro'; +import type { Steps as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline = await Astro.slots.render('tagline'), + items = [], + image = await Astro.slots.render('image'), + isReversed = false, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-5xl ${classes?.container ?? ''}`} bg={bg}> + <div class:list={['flex flex-col gap-8 md:gap-12', { 'md:flex-row-reverse': isReversed }, { 'md:flex-row': image }]}> + <div class:list={['md:py-4 md:self-center', { 'md:basis-1/2': image }, { 'w-full': !image }]}> + <Headline + title={title} + subtitle={subtitle} + tagline={tagline} + classes={{ + container: 'text-left rtl:text-right', + title: 'text-3xl lg:text-4xl', + ...((classes?.headline as object) ?? {}), + }} + /> + <Timeline items={items} classes={classes?.items as Record<string, never>} /> + </div> + { + image && ( + <div class="relative md:basis-1/2"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="inset-0 object-cover object-top w-full rounded-md shadow-lg md:absolute md:h-full bg-gray-400 dark:bg-slate-700" + widths={[400, 768]} + sizes="(max-width: 768px) 100vw, 432px" + width={432} + height={768} + layout="cover" + src={image?.src} + alt={image?.alt || ''} + /> + )} + </div> + ) + } + </div> +</WidgetWrapper> diff --git a/src/components/widgets/Steps2.astro b/src/components/widgets/Steps2.astro new file mode 100644 index 0000000..0891663 --- /dev/null +++ b/src/components/widgets/Steps2.astro @@ -0,0 +1,79 @@ +--- +import { Icon } from 'astro-icon/components'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Headline from '~/components/ui/Headline.astro'; +import Button from '~/components/ui/Button.astro'; +import type { Steps as Props } from '~/types'; + +const { + title = await Astro.slots.render('title'), + subtitle = await Astro.slots.render('subtitle'), + tagline, + callToAction = await Astro.slots.render('callToAction'), + items = [], + isReversed = false, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; + +// Function to make email addresses clickable +function makeEmailsClickable(text: string | undefined): string { + if (!text) return ''; + const emailRegex = /([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; + return text.replace(emailRegex, '<a href="mailto:$1" class="text-primary hover:text-secondary transition-colors">$1</a>'); +} +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <div class={`flex flex-col gap-8 md:gap-12 md:flex-row ${isReversed ? 'md:flex-row-reverse' : ''}`}> + <div class={`w-full lg:w-1/2 gap-8 md:gap-12 ${isReversed ? 'lg:ml-16 md:ml-8 ml-0' : 'lg:mr-16 md:mr-8 mr-0'}`}> + <Headline + title={title} + subtitle={subtitle} + tagline={tagline} + classes={{ + container: 'text-center md:text-left rtl:md:text-right mb-4 md:mb-8', + title: 'mb-4 text-3xl lg:text-4xl font-bold font-heading', + subtitle: 'mb-8 text-xl text-muted dark:text-slate-400', + // ...((classes?.headline as {}) ?? {}), + }} + /> + + <div class="w-full text-center md:text-left rtl:md:text-right"> + { + typeof callToAction === 'string' ? ( + <Fragment set:html={callToAction} /> + ) : ( + callToAction && + callToAction.text && + callToAction.href && <Button variant="primary" {...callToAction} class="mb-12 w-auto" /> + ) + } + </div> + </div> + <div class="w-full lg:w-1/2 px-0"> + <ul class="space-y-10"> + { + items && items.length + ? items.map(({ title: title2, description, icon }, index) => ( + <li class="flex md:-mx-4"> + <div class="pr-4 sm:pl-4 rtl:pr-0 rtl:pl-4 rtl:sm:pl-0 rtl:sm:pr-4"> + <span class="flex w-16 h-16 mx-auto items-center justify-center text-2xl font-bold rounded-full bg-gray-100 text-primary"> + {icon ? <Icon name={icon} class="w-6 h-6 icon-bold" /> : index + 1} + </span> + </div> + <div class="pl-4 rtl:pl-0 rtl:pr-4"> + <h3 class="mb-4 text-xl font-semibold font-heading" set:html={title2} /> + <p class="text-muted dark:text-gray-400" set:html={description ? makeEmailsClickable(description) : ''} /> + </div> + </li> + )) + : '' + } + </ul> + </div> + </div> +</WidgetWrapper> diff --git a/src/components/widgets/Testimonials.astro b/src/components/widgets/Testimonials.astro new file mode 100644 index 0000000..11db7b5 --- /dev/null +++ b/src/components/widgets/Testimonials.astro @@ -0,0 +1,75 @@ +--- +import Headline from '~/components/ui/Headline.astro'; +import WidgetWrapper from '~/components/ui/WidgetWrapper.astro'; +import Button from '~/components/ui/Button.astro'; +import Image from '~/components/common/Image.astro'; +import type { Testimonials as Props } from '~/types'; + +const { + title = '', + subtitle = '', + tagline = '', + testimonials = [], + callToAction, + + id, + isDark = false, + classes = {}, + bg = await Astro.slots.render('bg'), +} = Astro.props; +--- + +<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-6xl mx-auto ${classes?.container ?? ''}`} bg={bg}> + <Headline title={title} subtitle={subtitle} tagline={tagline} /> + + <div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6"> + { + testimonials && + testimonials.map(({ title, testimonial, name, job, image }) => ( + <div class="flex h-auto intersect-once motion-safe:md:intersect:animate-fade motion-safe:md:opacity-0 intersect-quarter"> + <div class="flex flex-col p-4 md:p-6 rounded-md shadow-xl dark:shadow-none dark:border dark:border-slate-600"> + {title && <h2 class="text-lg font-medium leading-6 pb-4">{title}</h2>} + {testimonial && ( + <blockquote class="flex-auto"> + <p class="text-muted">" {testimonial} "</p> + </blockquote> + )} + + <hr class="border-slate-200 dark:border-slate-600 my-4" /> + + <div class="flex items-center"> + {image && ( + <div class="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-600"> + {typeof image === 'string' ? ( + <Fragment set:html={image} /> + ) : ( + <Image + class="h-10 w-10 rounded-full border border-slate-200 dark:border-slate-600 min-w-full min-h-full" + width={40} + height={40} + widths={[400, 768]} + layout="fixed" + {...image} + /> + )} + </div> + )} + + <div class="grow ml-3 rtl:ml-0 rtl:mr-3"> + {name && <p class="text-base font-semibold">{name}</p>} + {job && <p class="text-xs text-muted">{job}</p>} + </div> + </div> + </div> + </div> + )) + } + </div> + { + callToAction && ( + <div class="flex justify-center mx-auto w-fit mt-8 md:mt-12 font-medium"> + <Button {...callToAction} /> + </div> + ) + } +</WidgetWrapper> diff --git a/src/config.yaml b/src/config.yaml new file mode 100644 index 0000000..0c5c1b1 --- /dev/null +++ b/src/config.yaml @@ -0,0 +1,68 @@ +site: + name: CustomWorks + site: 'https://www.customworks.pl' + base: '/' + trailingSlash: false + + googleSiteVerificationId: orcPxI47GSa-cRvY11tUe6iGg2IO_RPvnA1q95iEM3M + +# Default SEO metadata +metadata: + title: + default: CustomWorks – Detailing, wrapping, tuning | Profesjonalne usługi pielęgnacji samochodów – Bydgoszcz + template: '%s — CustomWorks' + description: "CustomWorks oferuje detailing, wrapping, tuning i powłoki ochronne. Renowacja lakieru, folie PPF, powłoki ceramiczne – Bydgoszcz, kujawsko-pomorskie." + robots: + index: true + follow: true + openGraph: + site_name: CustomWorks – Detailing, wrapping, tuning | Profesjonalne usługi pielęgnacji samochodów – Bydgoszcz + images: + - url: '~/assets/images/customworks-hero.webp' + width: 819 + height: 1024 + type: website + +i18n: + language: pl + textDirection: ltr + +apps: + blog: + isEnabled: false + postsPerPage: 6 + + post: + isEnabled: false + permalink: '/%slug%' # Variables: %slug%, %year%, %month%, %day%, %hour%, %minute%, %second%, %category% + robots: + index: true + + list: + isEnabled: false + pathname: 'blog' # Blog main path, you can change this to "articles" (/articles) + robots: + index: true + + category: + isEnabled: false + pathname: 'category' # Category main path /category/some-category, you can change this to "group" (/group/some-category) + robots: + index: true + + tag: + isEnabled: false + pathname: 'tag' # Tag main path /tag/some-tag, you can change this to "topics" (/topics/some-category) + robots: + index: false + + isRelatedPostsEnabled: false + relatedPostsCount: 4 + +analytics: + vendors: + googleAnalytics: + id: null # or "G-XXXXXXXXXX" + +ui: + theme: 'light:only' # Values: "system" | "light" | "dark" | "light:only" | "dark:only" diff --git a/src/content/config.ts b/src/content/config.ts new file mode 100644 index 0000000..71bc2f5 --- /dev/null +++ b/src/content/config.ts @@ -0,0 +1,70 @@ +import { z, defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const metadataDefinition = () => + z + .object({ + title: z.string().optional(), + ignoreTitleTemplate: z.boolean().optional(), + + canonical: z.string().url().optional(), + + robots: z + .object({ + index: z.boolean().optional(), + follow: z.boolean().optional(), + }) + .optional(), + + description: z.string().optional(), + + openGraph: z + .object({ + url: z.string().optional(), + siteName: z.string().optional(), + images: z + .array( + z.object({ + url: z.string(), + width: z.number().optional(), + height: z.number().optional(), + }) + ) + .optional(), + locale: z.string().optional(), + type: z.string().optional(), + }) + .optional(), + + twitter: z + .object({ + handle: z.string().optional(), + site: z.string().optional(), + cardType: z.string().optional(), + }) + .optional(), + }) + .optional(); + +const postCollection = defineCollection({ + loader: glob({ pattern: ['*.md', '*.mdx'], base: 'src/data/post' }), + schema: z.object({ + publishDate: z.date().optional(), + updateDate: z.date().optional(), + draft: z.boolean().optional(), + + title: z.string(), + excerpt: z.string().optional(), + image: z.string().optional(), + + category: z.string().optional(), + tags: z.array(z.string()).optional(), + author: z.string().optional(), + + metadata: metadataDefinition(), + }), +}); + +export const collections = { + post: postCollection, +}; diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..45786fb --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// <reference path="../.astro/types.d.ts" /> +/// <reference types="astro/client" /> +/// <reference types="vite/client" /> +/// <reference types="../vendor/integration/types.d.ts" /> diff --git a/src/layouts/LandingLayout.astro b/src/layouts/LandingLayout.astro new file mode 100644 index 0000000..b09878b --- /dev/null +++ b/src/layouts/LandingLayout.astro @@ -0,0 +1,30 @@ +--- +import PageLayout from '~/layouts/PageLayout.astro'; +import Header from '~/components/widgets/Header.astro'; + +import { headerData } from '~/navigation'; +import type { MetaData } from '~/types'; + +export interface Props { + metadata?: MetaData; +} + +const { metadata } = Astro.props; +--- + +<PageLayout metadata={metadata}> + <Fragment slot="announcement"> + <slot name="announcement" /> + </Fragment> + <Fragment slot="header"> + <slot name="header"> + <Header + links={headerData?.links[2] ? [headerData.links[2]] : undefined} + actions={[]} + showToggleTheme + position="right" + /> + </slot> + </Fragment> + <slot /> +</PageLayout> diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro new file mode 100644 index 0000000..524a26f --- /dev/null +++ b/src/layouts/Layout.astro @@ -0,0 +1,48 @@ +--- +import '~/assets/styles/tailwind.css'; + +import { I18N } from 'astrowind:config'; + +import CommonMeta from '~/components/common/CommonMeta.astro'; +import Favicons from '~/components/Favicons.astro'; +import CustomStyles from '~/components/CustomStyles.astro'; +import ApplyColorMode from '~/components/common/ApplyColorMode.astro'; +import Metadata from '~/components/common/Metadata.astro'; +import SiteVerification from '~/components/common/SiteVerification.astro'; +import Analytics from '~/components/common/Analytics.astro'; +import BasicScripts from '~/components/common/BasicScripts.astro'; + +// Comment the line below to disable View Transitions +import { ClientRouter } from 'astro:transitions'; + +import type { MetaData as MetaDataType } from '~/types'; + +export interface Props { + metadata?: MetaDataType; +} + +const { metadata = {} } = Astro.props; +const { language, textDirection } = I18N; +--- + +<!doctype html> +<html lang={language} dir={textDirection} class="2xl:text-[20px]"> + <head> + <CommonMeta /> + <Favicons /> + <CustomStyles /> + <ApplyColorMode /> + <Metadata {...metadata} /> + <SiteVerification /> + <Analytics /> + + <!-- Comment the line below to disable View Transitions --> + <ClientRouter fallback="swap" /> + </head> + + <body class="antialiased text-default bg-page tracking-tight"> + <slot /> + + <BasicScripts /> + </body> +</html> diff --git a/src/layouts/MarkdownLayout.astro b/src/layouts/MarkdownLayout.astro new file mode 100644 index 0000000..c8f5aa5 --- /dev/null +++ b/src/layouts/MarkdownLayout.astro @@ -0,0 +1,28 @@ +--- +import Layout from '~/layouts/PageLayout.astro'; + +import type { MetaData } from '~/types'; + +export interface Props { + frontmatter: { + title?: string; + }; +} + +const { frontmatter } = Astro.props; + +const metadata: MetaData = { + title: frontmatter?.title, +}; +--- + +<Layout metadata={metadata}> + <section class="px-4 py-16 sm:px-6 mx-auto lg:px-8 lg:py-20 max-w-4xl"> + <h1 class="font-bold font-heading text-4xl md:text-5xl leading-tighter tracking-tighter">{frontmatter.title}</h1> + <div + class="mx-auto prose prose-lg max-w-4xl dark:prose-invert dark:prose-headings:text-slate-300 prose-md prose-headings:font-heading prose-headings:leading-tighter prose-headings:tracking-tighter prose-headings:font-bold prose-a:text-primary dark:prose-a:text-primary prose-img:rounded-md prose-img:shadow-lg mt-8" + > + <slot /> + </div> + </section> +</Layout> diff --git a/src/layouts/PageLayout.astro b/src/layouts/PageLayout.astro new file mode 100644 index 0000000..eebcba9 --- /dev/null +++ b/src/layouts/PageLayout.astro @@ -0,0 +1,27 @@ +--- +import Layout from '~/layouts/Layout.astro'; +import Header from '~/components/widgets/Header.astro'; +import Footer from '~/components/widgets/Footer.astro'; + +import { headerData, footerData } from '~/navigation'; + +import type { MetaData } from '~/types'; + +export interface Props { + metadata?: MetaData; +} + +const { metadata } = Astro.props; +--- + +<Layout metadata={metadata}> + <slot name="header"> + <Header {...headerData} isSticky showRssFeed showToggleTheme /> + </slot> + <main> + <slot /> + </main> + <slot name="footer"> + <Footer {...footerData} /> + </slot> +</Layout> diff --git a/src/navigation.ts b/src/navigation.ts new file mode 100644 index 0000000..7186a35 --- /dev/null +++ b/src/navigation.ts @@ -0,0 +1,130 @@ +import { getPermalink, getBlogPermalink, getAsset } from './utils/permalinks'; + +export const headerData = { + links: [ + { + text: 'Homes', + links: [ + { + text: 'SaaS', + href: getPermalink('/homes/saas'), + }, + { + text: 'Startup', + href: getPermalink('/homes/startup'), + }, + { + text: 'Mobile App', + href: getPermalink('/homes/mobile-app'), + }, + { + text: 'Personal', + href: getPermalink('/homes/personal'), + }, + ], + }, + { + text: 'Pages', + links: [ + { + text: 'Features (Anchor Link)', + href: getPermalink('/#features'), + }, + { + text: 'Services', + href: getPermalink('/services'), + }, + { + text: 'Pricing', + href: getPermalink('/pricing'), + }, + { + text: 'About us', + href: getPermalink('/about'), + }, + { + text: 'Contact', + href: getPermalink('/contact'), + }, + { + text: 'Terms', + href: getPermalink('/terms'), + }, + { + text: 'Polityka prywatności', + href: getPermalink('/polityka-prywatnosci'), + }, + ], + }, + { + text: 'Landing', + links: [ + { + text: 'Lead Generation', + href: getPermalink('/landing/lead-generation'), + }, + { + text: 'Long-form Sales', + href: getPermalink('/landing/sales'), + }, + { + text: 'Click-Through', + href: getPermalink('/landing/click-through'), + }, + { + text: 'Product Details (or Services)', + href: getPermalink('/landing/product'), + }, + { + text: 'Coming Soon or Pre-Launch', + href: getPermalink('/landing/pre-launch'), + }, + { + text: 'Subscription', + href: getPermalink('/landing/subscription'), + }, + ], + }, + { + text: 'Blog', + links: [ + { + text: 'Blog List', + href: getBlogPermalink(), + }, + { + text: 'Article', + href: getPermalink('get-started-website-with-astro-tailwind-css', 'post'), + }, + { + text: 'Article (with MDX)', + href: getPermalink('markdown-elements-demo-post', 'post'), + }, + { + text: 'Category Page', + href: getPermalink('tutorials', 'category'), + }, + { + text: 'Tag Page', + href: getPermalink('astro', 'tag'), + }, + ], + }, + { + text: 'Widgets', + href: '#', + }, + ], + actions: [{ text: 'Download', href: 'https://github.com/onwidget/astrowind', target: '_blank' }], +}; + +export const footerData = { + links: [], + secondaryLinks: [], + socialLinks: [ + { text: 'Polityka prywatności', href: getPermalink('/polityka-prywatnosci') }, + ], + footNote: ` + Strona obsługiwana przez <a class="text-primary underline dark:text-muted" href="https://www.rycerz.xyz/">Craftknight</a> Copyright 2025 © Wszystkie prawa zastrzeżone + `, +}; diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..9ef7a08 --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,24 @@ +--- +import Layout from '~/layouts/Layout.astro'; +import { getHomePermalink } from '~/utils/permalinks'; + +const title = `Error 404`; +--- + +<Layout metadata={{ title }}> + <section class="flex items-center h-full p-16"> + <div class="container flex flex-col items-center justify-center px-5 mx-auto my-8"> + <div class="max-w-md text-center"> + <h2 class="mb-8 font-bold text-9xl"> + <span class="sr-only">Error</span> + <span class="text-primary">404</span> + </h2> + <p class="text-3xl font-semibold md:text-3xl">Sorry, we couldn't find this page.</p> + <p class="mt-4 mb-8 text-lg text-muted dark:text-slate-400"> + But dont worry, you can find plenty of other things on our homepage. + </p> + <a rel="noopener noreferrer" href={getHomePermalink()} class="btn ml-4">Back to homepage</a> + </div> + </div> + </section> +</Layout> diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..510a069 --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,195 @@ +--- +import Layout from '~/layouts/PageLayout.astro'; + +import Hero from '~/components/widgets/Hero2.astro'; +import Note from '~/components/widgets/Note.astro'; +import Features from '~/components/widgets/Features.astro'; +import Features2 from '~/components/widgets/Features2.astro'; +import Steps2 from '~/components/widgets/Steps2.astro'; +import Content from '~/components/widgets/Content.astro'; +import BlogLatestPosts from '~/components/widgets/BlogLatestPosts.astro'; +import FAQs from '~/components/widgets/FAQs.astro'; +import Stats from '~/components/widgets/Stats.astro'; +import CallToAction from '~/components/widgets/CallToAction.astro'; +import CallToActionImage from '~/components/widgets/CallToActionImage.astro'; + +const metadata = { + title: 'CustomWorks – Detailing, wrapping, tuning | Profesjonalne usługi pielęgnacji samochodów – Bydgoszcz', + ignoreTitleTemplate: true, +}; +--- + +<Layout metadata={metadata}> + <!-- Hero Widget ******************* --> + + <Hero + actions={[ + { + variant: 'primary', + text: 'Kontakt', + href: '#contact', + icon: 'tabler:mail', + }, + { text: 'Usługi', href: '#features' }, + ]} + image={{ src: '~/assets/images/hero-image.jpg', alt: 'CustomWorks - Profesjonalne usługi detailingu samochodowego' }} + > + <Fragment slot="title"> + <span class="text-accent dark:text-white"> + <span class="block md:hidden">CustomWorks</span> + <span class="hidden md:block">Detailing, wrapping, tuning</span> + </span> + </Fragment> + + <Fragment slot="subtitle"> + Twój specjalista od pielęgnacji samochodów + </Fragment> + </Hero> + + <!-- Oferowane usługi *************** --> + + <Features2 + id="features" + title="Usługi" + columns={2} + items={[ + { + title: 'Renowacja lakieru', + description: 'Przywracam blask i witalność Twojego samochodu, usuwając wszelkie rysy, zadrapania i oznaki zużycia', + icon: 'tabler:spray', + }, + { + title: 'Detailing wnętrz', + description: 'Pielęgnuję wnętrze Twojego samochodu, stosując zaawansowane metody czyszczenia i zabezpieczania materiałów', + icon: 'tabler:car', + }, + { + title: 'Powłoki ochronne', + description: 'Specjalizuję się w aplikacji powłok ceramicznych, grafenowych i kwarcowych, zapewniając najwyższą jakość ochrony lakieru, a jednocześnie zachwycając wyglądem', + icon: 'tabler:shield-check', + }, + { + title: 'Niewidzialne wycieraczki', + description: 'Zapewniam czyste i suche szyby dzięki rewolucyjnym niewidzialnym wycieraczkom, które utrzymują widoczność nawet w trudnych warunkach atmosferycznych', + icon: 'tabler:droplet', + }, + { + title: 'Ochrona lakieru foliami PPF', + description: 'Zabezpieczam Twoje auto przed uszkodzeniami mechanicznymi, odpryskami i rysami, dzięki innowacyjnym foliom ochronnym PPF', + icon: 'tabler:shield', + }, + { + title: 'Czyszczenie komory silnika', + description: 'Dbam o serce Twojego samochodu, przeprowadzając profesjonalne czyszczenie komory silnika oraz jego podzespołów', + icon: 'tabler:engine', + }, + { + title: 'Zmiana koloru foliami', + description: 'Daję Ci możliwość szybkiej i skutecznej metamorfozy Twojego pojazdu poprzez zmianę koloru za pomocą wysokiej jakości folii', + icon: 'tabler:palette', + }, + { + title: 'Tuning wizualny', + description: 'Realizuję Twoje wizje i pomysły na wyjątkowy wygląd samochodu, oferując szeroki zakres opcji stylizacji zewnętrznej i wewnętrznej', + icon: 'tabler:settings', + }, + ]} + > + </Features2> + + <!-- SEO1 **************** --> + + <Content + isReversed + title="O mnie" + items={[ + { + title: 'Profesjonalne usługi detailingu', + description: + 'W Custom Works zawsze dokładam wszelkich starań, aby spełnić oczekiwania moich klientów, a nawet je przekroczyć.', + }, + { + title: 'Doświadczenie i jakość', + description: + 'Działam z myślą o Twoim zadowoleniu, gwarantując solidność i niezawodność usług.', + }, + { + title: 'Kompleksowa opieka', + description: + 'Zapraszam do kontaktu ze mną, by dowiedzieć się więcej o tym, jak mogę nadać Twojemu samochodowi wyjątkowy charakter!', + }, + ]} + image={{ + src: '~/assets/images/content-image.webp', + alt: 'CustomWorks - Profesjonalne usługi detailingu samochodowego', + }} + > + <Fragment slot="content"> + <h3 class="text-2xl font-bold tracking-tight dark:text-white sm:text-3xl mb-2">Profesjonalna opieka nad Twoim samochodem</h3> + W Custom Works zawsze dokładam wszelkich starań, aby spełnić oczekiwania moich klientów, a nawet je przekroczyć. Działam z myślą o Twoim zadowoleniu, gwarantując solidność i niezawodność usług. Zapraszam do kontaktu ze mną, by dowiedzieć się więcej o tym, jak mogę nadać Twojemu samochodowi wyjątkowy charakter! + </Fragment> + + <Fragment slot="bg"> + <div class="absolute inset-0 bg-section dark:bg-transparent"></div> + </Fragment> + </Content> + + <!-- SEO2**************** --> + + <Content + isAfterContent + title="Dlaczego CustomWorks" + items={[ + { + title: 'Doświadczenie i profesjonalizm', + description: 'Wieloletnie doświadczenie w branży motoryzacyjnej i ciągłe doskonalenie umiejętności.', + }, + { + title: 'Najwyższej jakości materiały', + description: 'Używam tylko sprawdzonych i certyfikowanych produktów od renomowanych producentów.', + }, + { + title: 'Szczegółowe podejście', + description: 'Każdy samochód traktuję indywidualnie, dostosowując metody pracy do jego specyfiki.', + }, + { + title: 'Gwarancja satysfakcji', + description: 'Zadowolenie klienta jest moim priorytetem - każdy projekt wykonuję z najwyższą starannością.', + }, + ]} + image={{ + src: '~/assets/images/content-image2.webp', + alt: 'CustomWorks - Profesjonalne usługi detailingu samochodowego', + }} + > + <Fragment slot="content"> + <h3 class="text-2xl font-bold tracking-tight dark:text-white sm:text-3xl mb-2">Kompleksowe rozwiązania dla Twojego samochodu</h3> + Oferuję pełen zakres usług detailingowych, od podstawowego mycia po zaawansowane powłoki ochronne. Każdy samochód traktuję indywidualnie, dostosowując metody pracy do jego specyfiki. Używam tylko sprawdzonych i certyfikowanych produktów, gwarantując trwałe i satysfakcjonujące rezultaty. + </Fragment> + + <Fragment slot="bg"> + <div class="absolute inset-0 bg-section dark:bg-transparent"></div> + </Fragment> + </Content> + + <!-- Kontakt **************** --> + + <Steps2 + id="contact" + title="Kontakt" + subtitle="Skontaktuj się ze mną, aby omówić szczegóły Twojego projektu!" + items={[ + { + title: 'Telefon', + description: '+48 790-209-770', + icon: 'tabler:phone', + }, + { + title: 'Adres', + description: 'Bydgoszcz, Miedzyń', + icon: 'tabler:map-pin', + }, + ]} + /> + +</Layout> diff --git a/src/pages/polityka-prywatnosci.md b/src/pages/polityka-prywatnosci.md new file mode 100644 index 0000000..09b1ba1 --- /dev/null +++ b/src/pages/polityka-prywatnosci.md @@ -0,0 +1,37 @@ +--- +title: 'Polityka Prywatności' +layout: '~/layouts/MarkdownLayout.astro' +--- + +## Przetwarzanie Danych & Zgodność z RODO + +### Administrator Danych + +Administratorem danych dla tej strony internetowej (**customworks.pl**) jest właściciel strony. Na tej stronie nie są zbierane ani przetwarzane żadne dane osobowe (PII). + +### Jakie Dane Są Zbierane? + +**Nie** zbieramy żadnych danych osobowych. +**Nie** używamy plików cookie. +**Nie** śledzimy użytkowników między stronami. + +### Hosting + +Ta strona internetowa jest hostowana na **Cloudflare Pages**, bezpiecznej i zgodnej z RODO platformie. Podczas gdy Cloudflare może tymczasowo przetwarzać dane techniczne (takie jak adresy IP) w celu dostarczania i ochrony strony internetowej, domyślnie nie ma miejsca śledzenie ani profilowanie odwiedzających. + +Polityka prywatności Cloudflare jest dostępna tutaj: +[https://www.cloudflare.com/privacypolicy/](https://www.cloudflare.com/privacypolicy/) + +### Twoje Prawa Zgodnie z RODO + +Ponieważ na tej stronie nie są zbierane ani przechowywane żadne dane osobowe, prawa przyznane na mocy RODO (np. prawo dostępu, sprostowania, usunięcia lub przenoszenia danych) **nie mają zastosowania** w tym kontekście. + +Jeśli masz jakiekolwiek pytania dotyczące przetwarzania danych lub prywatności, skontaktuj się z nami za pomocą informacji kontaktowych dostępnych na stronie. + +## Kontakt + +Jeśli masz pytania dotyczące tej Polityki Prywatności, możesz skontaktować się z nami za pomocą formularza kontaktowego dostępnego na stronie głównej. + +<a href="/#contact" class="inline-flex items-center justify-center px-6 py-3 text-base font-medium text-white bg-primary-600 border border-transparent rounded-md shadow-sm hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"> + Skontaktuj się z nami +</a> diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..81dca02 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,281 @@ +import type { AstroComponentFactory } from 'astro/runtime/server/index.js'; +import type { HTMLAttributes, ImageMetadata } from 'astro/types'; + +export interface Post { + /** A unique ID number that identifies a post. */ + id: string; + + /** A post’s unique slug – part of the post’s URL based on its name, i.e. a post called “My Sample Page” has a slug “my-sample-page”. */ + slug: string; + + /** */ + permalink: string; + + /** */ + publishDate: Date; + /** */ + updateDate?: Date; + + /** */ + title: string; + /** Optional summary of post content. */ + excerpt?: string; + /** */ + image?: ImageMetadata | string; + + /** */ + category?: Taxonomy; + /** */ + tags?: Taxonomy[]; + /** */ + author?: string; + + /** */ + metadata?: MetaData; + + /** */ + draft?: boolean; + + /** */ + Content?: AstroComponentFactory; + content?: string; + + /** */ + readingTime?: number; +} + +export interface Taxonomy { + slug: string; + title: string; +} + +export interface MetaData { + title?: string; + ignoreTitleTemplate?: boolean; + + canonical?: string; + + robots?: MetaDataRobots; + + description?: string; + + openGraph?: MetaDataOpenGraph; + twitter?: MetaDataTwitter; +} + +export interface MetaDataRobots { + index?: boolean; + follow?: boolean; +} + +export interface MetaDataImage { + url: string; + width?: number; + height?: number; +} + +export interface MetaDataOpenGraph { + url?: string; + siteName?: string; + images?: Array<MetaDataImage>; + locale?: string; + type?: string; +} + +export interface MetaDataTwitter { + handle?: string; + site?: string; + cardType?: string; +} + +export interface Image { + src: string; + alt?: string; +} + +export interface Video { + src: string; + type?: string; +} + +export interface Widget { + id?: string; + isDark?: boolean; + bg?: string; + classes?: Record<string, string | Record<string, string>>; +} + +export interface Headline { + title?: string; + subtitle?: string; + tagline?: string; + classes?: Record<string, string>; +} + +interface TeamMember { + name?: string; + job?: string; + image?: Image; + socials?: Array<Social>; + description?: string; + classes?: Record<string, string>; +} + +interface Social { + icon?: string; + href?: string; +} + +export interface Stat { + amount?: number | string; + title?: string; + icon?: string; +} + +export interface Item { + title?: string; + description?: string; + icon?: string; + classes?: Record<string, string>; + callToAction?: CallToAction; + image?: Image; +} + +export interface Price { + title?: string; + subtitle?: string; + description?: string; + price?: number | string; + period?: string; + items?: Array<Item>; + callToAction?: CallToAction; + hasRibbon?: boolean; + ribbonTitle?: string; +} + +export interface Testimonial { + title?: string; + testimonial?: string; + name?: string; + job?: string; + image?: string | unknown; +} + +export interface Input { + type: HTMLInputTypeAttribute; + name: string; + label?: string; + autocomplete?: string; + placeholder?: string; +} + +export interface Textarea { + label?: string; + name?: string; + placeholder?: string; + rows?: number; +} + +export interface Disclaimer { + label?: string; +} + +// COMPONENTS +export interface CallToAction extends Omit<HTMLAttributes<'a'>, 'slot'> { + variant?: 'primary' | 'secondary' | 'tertiary' | 'link'; + text?: string; + icon?: string; + classes?: Record<string, string>; + type?: 'button' | 'submit' | 'reset'; +} + +export interface ItemGrid { + items?: Array<Item>; + columns?: number; + defaultIcon?: string; + classes?: Record<string, string>; +} + +export interface Collapse { + iconUp?: string; + iconDown?: string; + items?: Array<Item>; + columns?: number; + classes?: Record<string, string>; +} + +export interface Form { + inputs?: Array<Input>; + textarea?: Textarea; + disclaimer?: Disclaimer; + button?: string; + description?: string; +} + +// WIDGETS +export interface Hero extends Omit<Headline, 'classes'>, Omit<Widget, 'isDark' | 'classes'> { + content?: string; + actions?: string | CallToAction[]; + image?: string | unknown; +} + +export interface Team extends Omit<Headline, 'classes'>, Widget { + team?: Array<TeamMember>; +} + +export interface Stats extends Omit<Headline, 'classes'>, Widget { + stats?: Array<Stat>; +} + +export interface Pricing extends Omit<Headline, 'classes'>, Widget { + prices?: Array<Price>; +} + +export interface Testimonials extends Omit<Headline, 'classes'>, Widget { + testimonials?: Array<Testimonial>; + callToAction?: CallToAction; +} + +export interface Brands extends Omit<Headline, 'classes'>, Widget { + icons?: Array<string>; + images?: Array<Image>; +} + +export interface Features extends Omit<Headline, 'classes'>, Widget { + image?: string | unknown; + video?: Video; + items?: Array<Item>; + columns?: number; + defaultIcon?: string; + callToAction1?: CallToAction; + callToAction2?: CallToAction; + isReversed?: boolean; + isBeforeContent?: boolean; + isAfterContent?: boolean; +} + +export interface Faqs extends Omit<Headline, 'classes'>, Widget { + iconUp?: string; + iconDown?: string; + items?: Array<Item>; + columns?: number; +} + +export interface Steps extends Omit<Headline, 'classes'>, Widget { + items?: Array<Item>; + callToAction?: string | CallToAction; + image?: string | Image; + isReversed?: boolean; +} + +export interface Content extends Omit<Headline, 'classes'>, Widget { + content?: string; + image?: string | unknown; + items?: Array<Item>; + columns?: number; + isReversed?: boolean; + isAfterContent?: boolean; + callToAction?: CallToAction; +} + +export interface Contact extends Omit<Headline, 'classes'>, Form, Widget {} diff --git a/src/utils/blog.ts b/src/utils/blog.ts new file mode 100644 index 0000000..d0fa4e2 --- /dev/null +++ b/src/utils/blog.ts @@ -0,0 +1,281 @@ +import type { PaginateFunction } from 'astro'; +import { getCollection, render } from 'astro:content'; +import type { CollectionEntry } from 'astro:content'; +import type { Post } from '~/types'; +import { APP_BLOG } from 'astrowind:config'; +import { cleanSlug, trimSlash, BLOG_BASE, POST_PERMALINK_PATTERN, CATEGORY_BASE, TAG_BASE } from './permalinks'; + +const generatePermalink = async ({ + id, + slug, + publishDate, + category, +}: { + id: string; + slug: string; + publishDate: Date; + category: string | undefined; +}) => { + const year = String(publishDate.getFullYear()).padStart(4, '0'); + const month = String(publishDate.getMonth() + 1).padStart(2, '0'); + const day = String(publishDate.getDate()).padStart(2, '0'); + const hour = String(publishDate.getHours()).padStart(2, '0'); + const minute = String(publishDate.getMinutes()).padStart(2, '0'); + const second = String(publishDate.getSeconds()).padStart(2, '0'); + + const permalink = POST_PERMALINK_PATTERN.replace('%slug%', slug) + .replace('%id%', id) + .replace('%category%', category || '') + .replace('%year%', year) + .replace('%month%', month) + .replace('%day%', day) + .replace('%hour%', hour) + .replace('%minute%', minute) + .replace('%second%', second); + + return permalink + .split('/') + .map((el) => trimSlash(el)) + .filter((el) => !!el) + .join('/'); +}; + +const getNormalizedPost = async (post: CollectionEntry<'post'>): Promise<Post> => { + const { id, data } = post; + const { Content, remarkPluginFrontmatter } = await render(post); + + const { + publishDate: rawPublishDate = new Date(), + updateDate: rawUpdateDate, + title, + excerpt, + image, + tags: rawTags = [], + category: rawCategory, + author, + draft = false, + metadata = {}, + } = data; + + const slug = cleanSlug(id); // cleanSlug(rawSlug.split('/').pop()); + const publishDate = new Date(rawPublishDate); + const updateDate = rawUpdateDate ? new Date(rawUpdateDate) : undefined; + + const category = rawCategory + ? { + slug: cleanSlug(rawCategory), + title: rawCategory, + } + : undefined; + + const tags = rawTags.map((tag: string) => ({ + slug: cleanSlug(tag), + title: tag, + })); + + return { + id: id, + slug: slug, + permalink: await generatePermalink({ id, slug, publishDate, category: category?.slug }), + + publishDate: publishDate, + updateDate: updateDate, + + title: title, + excerpt: excerpt, + image: image, + + category: category, + tags: tags, + author: author, + + draft: draft, + + metadata, + + Content: Content, + // or 'content' in case you consume from API + + readingTime: remarkPluginFrontmatter?.readingTime, + }; +}; + +const load = async function (): Promise<Array<Post>> { + const posts = await getCollection('post'); + const normalizedPosts = posts.map(async (post) => await getNormalizedPost(post)); + + const results = (await Promise.all(normalizedPosts)) + .sort((a, b) => b.publishDate.valueOf() - a.publishDate.valueOf()) + .filter((post) => !post.draft); + + return results; +}; + +let _posts: Array<Post>; + +/** */ +export const isBlogEnabled = APP_BLOG.isEnabled; +export const isRelatedPostsEnabled = APP_BLOG.isRelatedPostsEnabled; +export const isBlogListRouteEnabled = APP_BLOG.list.isEnabled; +export const isBlogPostRouteEnabled = APP_BLOG.post.isEnabled; +export const isBlogCategoryRouteEnabled = APP_BLOG.category.isEnabled; +export const isBlogTagRouteEnabled = APP_BLOG.tag.isEnabled; + +export const blogListRobots = APP_BLOG.list.robots; +export const blogPostRobots = APP_BLOG.post.robots; +export const blogCategoryRobots = APP_BLOG.category.robots; +export const blogTagRobots = APP_BLOG.tag.robots; + +export const blogPostsPerPage = APP_BLOG?.postsPerPage; + +/** */ +export const fetchPosts = async (): Promise<Array<Post>> => { + if (!_posts) { + _posts = await load(); + } + + return _posts; +}; + +/** */ +export const findPostsBySlugs = async (slugs: Array<string>): Promise<Array<Post>> => { + if (!Array.isArray(slugs)) return []; + + const posts = await fetchPosts(); + + return slugs.reduce(function (r: Array<Post>, slug: string) { + posts.some(function (post: Post) { + return slug === post.slug && r.push(post); + }); + return r; + }, []); +}; + +/** */ +export const findPostsByIds = async (ids: Array<string>): Promise<Array<Post>> => { + if (!Array.isArray(ids)) return []; + + const posts = await fetchPosts(); + + return ids.reduce(function (r: Array<Post>, id: string) { + posts.some(function (post: Post) { + return id === post.id && r.push(post); + }); + return r; + }, []); +}; + +/** */ +export const findLatestPosts = async ({ count }: { count?: number }): Promise<Array<Post>> => { + const _count = count || 4; + const posts = await fetchPosts(); + + return posts ? posts.slice(0, _count) : []; +}; + +/** */ +export const getStaticPathsBlogList = async ({ paginate }: { paginate: PaginateFunction }) => { + if (!isBlogEnabled || !isBlogListRouteEnabled) return []; + return paginate(await fetchPosts(), { + params: { blog: BLOG_BASE || undefined }, + pageSize: blogPostsPerPage, + }); +}; + +/** */ +export const getStaticPathsBlogPost = async () => { + if (!isBlogEnabled || !isBlogPostRouteEnabled) return []; + return (await fetchPosts()).flatMap((post) => ({ + params: { + blog: post.permalink, + }, + props: { post }, + })); +}; + +/** */ +export const getStaticPathsBlogCategory = async ({ paginate }: { paginate: PaginateFunction }) => { + if (!isBlogEnabled || !isBlogCategoryRouteEnabled) return []; + + const posts = await fetchPosts(); + const categories = {}; + posts.map((post) => { + if (post.category?.slug) { + categories[post.category?.slug] = post.category; + } + }); + + return Array.from(Object.keys(categories)).flatMap((categorySlug) => + paginate( + posts.filter((post) => post.category?.slug && categorySlug === post.category?.slug), + { + params: { category: categorySlug, blog: CATEGORY_BASE || undefined }, + pageSize: blogPostsPerPage, + props: { category: categories[categorySlug] }, + } + ) + ); +}; + +/** */ +export const getStaticPathsBlogTag = async ({ paginate }: { paginate: PaginateFunction }) => { + if (!isBlogEnabled || !isBlogTagRouteEnabled) return []; + + const posts = await fetchPosts(); + const tags = {}; + posts.map((post) => { + if (Array.isArray(post.tags)) { + post.tags.map((tag) => { + tags[tag?.slug] = tag; + }); + } + }); + + return Array.from(Object.keys(tags)).flatMap((tagSlug) => + paginate( + posts.filter((post) => Array.isArray(post.tags) && post.tags.find((elem) => elem.slug === tagSlug)), + { + params: { tag: tagSlug, blog: TAG_BASE || undefined }, + pageSize: blogPostsPerPage, + props: { tag: tags[tagSlug] }, + } + ) + ); +}; + +/** */ +export async function getRelatedPosts(originalPost: Post, maxResults: number = 4): Promise<Post[]> { + const allPosts = await fetchPosts(); + const originalTagsSet = new Set(originalPost.tags ? originalPost.tags.map((tag) => tag.slug) : []); + + const postsWithScores = allPosts.reduce((acc: { post: Post; score: number }[], iteratedPost: Post) => { + if (iteratedPost.slug === originalPost.slug) return acc; + + let score = 0; + if (iteratedPost.category && originalPost.category && iteratedPost.category.slug === originalPost.category.slug) { + score += 5; + } + + if (iteratedPost.tags) { + iteratedPost.tags.forEach((tag) => { + if (originalTagsSet.has(tag.slug)) { + score += 1; + } + }); + } + + acc.push({ post: iteratedPost, score }); + return acc; + }, []); + + postsWithScores.sort((a, b) => b.score - a.score); + + const selectedPosts: Post[] = []; + let i = 0; + while (selectedPosts.length < maxResults && i < postsWithScores.length) { + selectedPosts.push(postsWithScores[i].post); + i++; + } + + return selectedPosts; +} diff --git a/src/utils/directories.ts b/src/utils/directories.ts new file mode 100644 index 0000000..b754797 --- /dev/null +++ b/src/utils/directories.ts @@ -0,0 +1,18 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** */ +export const getProjectRootDir = (): string => { + const mode = import.meta.env.MODE; + + return mode === 'production' ? path.join(__dirname, '../') : path.join(__dirname, '../../'); +}; + +const __srcFolder = path.join(getProjectRootDir(), '/src'); + +/** */ +export const getRelativeUrlByFilePath = (filepath: string): string => { + return filepath.replace(__srcFolder, ''); +}; diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts new file mode 100644 index 0000000..bf33632 --- /dev/null +++ b/src/utils/frontmatter.ts @@ -0,0 +1,50 @@ +import getReadingTime from 'reading-time'; +import { toString } from 'mdast-util-to-string'; +import { visit } from 'unist-util-visit'; +import type { RehypePlugin, RemarkPlugin } from '@astrojs/markdown-remark'; + +export const readingTimeRemarkPlugin: RemarkPlugin = () => { + return function (tree, file) { + const textOnPage = toString(tree); + const readingTime = Math.ceil(getReadingTime(textOnPage).minutes); + + if (typeof file?.data?.astro?.frontmatter !== 'undefined') { + file.data.astro.frontmatter.readingTime = readingTime; + } + }; +}; + +export const responsiveTablesRehypePlugin: RehypePlugin = () => { + return function (tree) { + if (!tree.children) return; + + for (let i = 0; i < tree.children.length; i++) { + const child = tree.children[i]; + + if (child.type === 'element' && child.tagName === 'table') { + tree.children[i] = { + type: 'element', + tagName: 'div', + properties: { + style: 'overflow:auto', + }, + children: [child], + }; + + i++; + } + } + }; +}; + +export const lazyImagesRehypePlugin: RehypePlugin = () => { + return function (tree) { + if (!tree.children) return; + + visit(tree, 'element', function (node) { + if (node.tagName === 'img') { + node.properties.loading = 'lazy'; + } + }); + }; +}; diff --git a/src/utils/images-optimization.ts b/src/utils/images-optimization.ts new file mode 100644 index 0000000..67eea8b --- /dev/null +++ b/src/utils/images-optimization.ts @@ -0,0 +1,351 @@ +import { getImage } from 'astro:assets'; +import { transformUrl, parseUrl } from 'unpic'; + +import type { ImageMetadata } from 'astro'; +import type { HTMLAttributes } from 'astro/types'; + +type Layout = 'fixed' | 'constrained' | 'fullWidth' | 'cover' | 'responsive' | 'contained'; + +export interface ImageProps extends Omit<HTMLAttributes<'img'>, 'src'> { + src?: string | ImageMetadata | null; + width?: string | number | null; + height?: string | number | null; + alt?: string | null; + loading?: 'eager' | 'lazy' | null; + decoding?: 'sync' | 'async' | 'auto' | null; + style?: string; + srcset?: string | null; + sizes?: string | null; + fetchpriority?: 'high' | 'low' | 'auto' | null; + + layout?: Layout; + widths?: number[] | null; + aspectRatio?: string | number | null; + objectPosition?: string; + + format?: string; +} + +export type ImagesOptimizer = ( + image: ImageMetadata | string, + breakpoints: number[], + width?: number, + height?: number, + format?: string +) => Promise<Array<{ src: string; width: number }>>; + +/* ******* */ +const config = { + // FIXME: Use this when image.width is minor than deviceSizes + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + + deviceSizes: [ + 640, // older and lower-end phones + 750, // iPhone 6-8 + 828, // iPhone XR/11 + 960, // older horizontal phones + 1080, // iPhone 6-8 Plus + 1280, // 720p + 1668, // Various iPads + 1920, // 1080p + 2048, // QXGA + 2560, // WQXGA + 3200, // QHD+ + 3840, // 4K + 4480, // 4.5K + 5120, // 5K + 6016, // 6K + ], + + formats: ['image/webp'], +}; + +const computeHeight = (width: number, aspectRatio: number) => { + return Math.floor(width / aspectRatio); +}; + +const parseAspectRatio = (aspectRatio: number | string | null | undefined): number | undefined => { + if (typeof aspectRatio === 'number') return aspectRatio; + + if (typeof aspectRatio === 'string') { + const match = aspectRatio.match(/(\d+)\s*[/:]\s*(\d+)/); + + if (match) { + const [, num, den] = match.map(Number); + if (den && !isNaN(num)) return num / den; + } else { + const numericValue = parseFloat(aspectRatio); + if (!isNaN(numericValue)) return numericValue; + } + } + + return undefined; +}; + +/** + * Gets the `sizes` attribute for an image, based on the layout and width + */ +export const getSizes = (width?: number, layout?: Layout): string | undefined => { + if (!width || !layout) { + return undefined; + } + switch (layout) { + // If screen is wider than the max size, image width is the max size, + // otherwise it's the width of the screen + case `constrained`: + return `(min-width: ${width}px) ${width}px, 100vw`; + + // Image is always the same width, whatever the size of the screen + case `fixed`: + return `${width}px`; + + // Image is always the width of the screen + case `fullWidth`: + return `100vw`; + + default: + return undefined; + } +}; + +const pixelate = (value?: number) => (value || value === 0 ? `${value}px` : undefined); + +const getStyle = ({ + width, + height, + aspectRatio, + layout, + objectFit = 'cover', + objectPosition = 'center', + background, +}: { + width?: number; + height?: number; + aspectRatio?: number; + objectFit?: string; + objectPosition?: string; + layout?: string; + background?: string; +}) => { + const styleEntries: Array<[prop: string, value: string | undefined]> = [ + ['object-fit', objectFit], + ['object-position', objectPosition], + ]; + + // If background is a URL, set it to cover the image and not repeat + if (background?.startsWith('https:') || background?.startsWith('http:') || background?.startsWith('data:')) { + styleEntries.push(['background-image', `url(${background})`]); + styleEntries.push(['background-size', 'cover']); + styleEntries.push(['background-repeat', 'no-repeat']); + } else { + styleEntries.push(['background', background]); + } + if (layout === 'fixed') { + styleEntries.push(['width', pixelate(width)]); + styleEntries.push(['height', pixelate(height)]); + styleEntries.push(['object-position', 'top left']); + } + if (layout === 'constrained') { + styleEntries.push(['max-width', pixelate(width)]); + styleEntries.push(['max-height', pixelate(height)]); + styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]); + styleEntries.push(['width', '100%']); + } + if (layout === 'fullWidth') { + styleEntries.push(['width', '100%']); + styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]); + styleEntries.push(['height', pixelate(height)]); + } + if (layout === 'responsive') { + styleEntries.push(['width', '100%']); + styleEntries.push(['height', 'auto']); + styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]); + } + if (layout === 'contained') { + styleEntries.push(['max-width', '100%']); + styleEntries.push(['max-height', '100%']); + styleEntries.push(['object-fit', 'contain']); + styleEntries.push(['aspect-ratio', aspectRatio ? `${aspectRatio}` : undefined]); + } + if (layout === 'cover') { + styleEntries.push(['max-width', '100%']); + styleEntries.push(['max-height', '100%']); + } + + const styles = Object.fromEntries(styleEntries.filter(([, value]) => value)); + + return Object.entries(styles) + .map(([key, value]) => `${key}: ${value};`) + .join(' '); +}; + +const getBreakpoints = ({ + width, + breakpoints, + layout, +}: { + width?: number; + breakpoints?: number[]; + layout: Layout; +}): number[] => { + if (layout === 'fullWidth' || layout === 'cover' || layout === 'responsive' || layout === 'contained') { + return breakpoints || config.deviceSizes; + } + if (!width) { + return []; + } + const doubleWidth = width * 2; + if (layout === 'fixed') { + return [width, doubleWidth]; + } + if (layout === 'constrained') { + return [ + // Always include the image at 1x and 2x the specified width + width, + doubleWidth, + // Filter out any resolutions that are larger than the double-res image + ...(breakpoints || config.deviceSizes).filter((w) => w < doubleWidth), + ]; + } + + return []; +}; + +/* ** */ +export const astroAssetsOptimizer: ImagesOptimizer = async ( + image, + breakpoints, + _width, + _height, + format = undefined +) => { + if (!image) { + return []; + } + + return Promise.all( + breakpoints.map(async (w: number) => { + const result = await getImage({ src: image, width: w, inferSize: true, ...(format ? { format: format } : {}) }); + + return { + src: result?.src, + width: result?.attributes?.width ?? w, + height: result?.attributes?.height, + }; + }) + ); +}; + +export const isUnpicCompatible = (image: string) => { + return typeof parseUrl(image) !== 'undefined'; +}; + +/* ** */ +export const unpicOptimizer: ImagesOptimizer = async (image, breakpoints, width, height, format = undefined) => { + if (!image || typeof image !== 'string') { + return []; + } + + const urlParsed = parseUrl(image); + if (!urlParsed) { + return []; + } + + return Promise.all( + breakpoints.map(async (w: number) => { + const _height = width && height ? computeHeight(w, width / height) : height; + const url = + transformUrl({ + url: image, + width: w, + height: _height, + cdn: urlParsed.cdn, + ...(format ? { format: format } : {}), + }) || image; + return { + src: String(url), + width: w, + height: _height, + }; + }) + ); +}; + +/* ** */ +export async function getImagesOptimized( + image: ImageMetadata | string, + { + src: _, + width, + height, + sizes, + aspectRatio, + objectPosition, + widths, + layout = 'constrained', + style = '', + format, + ...rest + }: ImageProps, + transform: ImagesOptimizer = () => Promise.resolve([]) +): Promise<{ src: string; attributes: HTMLAttributes<'img'> }> { + if (typeof image !== 'string') { + width ||= Number(image.width) || undefined; + height ||= typeof width === 'number' ? computeHeight(width, image.width / image.height) : undefined; + } + + width = (width && Number(width)) || undefined; + height = (height && Number(height)) || undefined; + + widths ||= config.deviceSizes; + sizes ||= getSizes(Number(width) || undefined, layout); + aspectRatio = parseAspectRatio(aspectRatio); + + // Calculate dimensions from aspect ratio + if (aspectRatio) { + if (width) { + if (height) { + /* empty */ + } else { + height = width / aspectRatio; + } + } else if (height) { + width = Number(height * aspectRatio); + } else if (layout !== 'fullWidth') { + // Fullwidth images have 100% width, so aspectRatio is applicable + console.error('When aspectRatio is set, either width or height must also be set'); + console.error('Image', image); + } + } else if (width && height) { + aspectRatio = width / height; + } else if (layout !== 'fullWidth') { + // Fullwidth images don't need dimensions + console.error('Either aspectRatio or both width and height must be set'); + console.error('Image', image); + } + + let breakpoints = getBreakpoints({ width: width, breakpoints: widths, layout: layout }); + breakpoints = [...new Set(breakpoints)].sort((a, b) => a - b); + + const srcset = (await transform(image, breakpoints, Number(width) || undefined, Number(height) || undefined, format)) + .map(({ src, width }) => `${src} ${width}w`) + .join(', '); + + return { + src: typeof image === 'string' ? image : image.src, + attributes: { + width: width, + height: height, + srcset: srcset || undefined, + sizes: sizes, + style: `${getStyle({ + width: width, + height: height, + aspectRatio: aspectRatio, + objectPosition: objectPosition, + layout: layout, + })}${style ?? ''}`, + ...rest, + }, + }; +} diff --git a/src/utils/images.ts b/src/utils/images.ts new file mode 100644 index 0000000..d6f14f0 --- /dev/null +++ b/src/utils/images.ts @@ -0,0 +1,111 @@ +import { isUnpicCompatible, unpicOptimizer, astroAssetsOptimizer } from './images-optimization'; +import type { ImageMetadata } from 'astro'; +import type { OpenGraph } from '@astrolib/seo'; + +const load = async function () { + let images: Record<string, () => Promise<unknown>> | undefined = undefined; + try { + images = import.meta.glob('~/assets/images/**/*.{jpeg,jpg,png,tiff,webp,gif,svg,JPEG,JPG,PNG,TIFF,WEBP,GIF,SVG}'); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + // continue regardless of error + } + return images; +}; + +let _images: Record<string, () => Promise<unknown>> | undefined = undefined; + +/** */ +export const fetchLocalImages = async () => { + _images = _images || (await load()); + return _images; +}; + +/** */ +export const findImage = async ( + imagePath?: string | ImageMetadata | null +): Promise<string | ImageMetadata | undefined | null> => { + // Not string + if (typeof imagePath !== 'string') { + return imagePath; + } + + // Absolute paths + if (imagePath.startsWith('http://') || imagePath.startsWith('https://') || imagePath.startsWith('/')) { + return imagePath; + } + + // Relative paths or not "~/assets/" + if (!imagePath.startsWith('~/assets/images')) { + return imagePath; + } + + const images = await fetchLocalImages(); + const key = imagePath.replace('~/', '/src/'); + + return images && typeof images[key] === 'function' + ? ((await images[key]()) as { default: ImageMetadata })['default'] + : null; +}; + +/** */ +export const adaptOpenGraphImages = async ( + openGraph: OpenGraph = {}, + astroSite: URL | undefined = new URL('') +): Promise<OpenGraph> => { + if (!openGraph?.images?.length) { + return openGraph; + } + + const images = openGraph.images; + const defaultWidth = 1200; + const defaultHeight = 626; + + const adaptedImages = await Promise.all( + images.map(async (image) => { + if (image?.url) { + const resolvedImage = (await findImage(image.url)) as ImageMetadata | string | undefined; + if (!resolvedImage) { + return { + url: '', + }; + } + + let _image; + + if ( + typeof resolvedImage === 'string' && + (resolvedImage.startsWith('http://') || resolvedImage.startsWith('https://')) && + isUnpicCompatible(resolvedImage) + ) { + _image = (await unpicOptimizer(resolvedImage, [defaultWidth], defaultWidth, defaultHeight, 'jpg'))[0]; + } else if (resolvedImage) { + const dimensions = + typeof resolvedImage !== 'string' && resolvedImage?.width <= defaultWidth + ? [resolvedImage?.width, resolvedImage?.height] + : [defaultWidth, defaultHeight]; + _image = ( + await astroAssetsOptimizer(resolvedImage, [dimensions[0]], dimensions[0], dimensions[1], 'jpg') + )[0]; + } + + if (typeof _image === 'object') { + return { + url: 'src' in _image && typeof _image.src === 'string' ? String(new URL(_image.src, astroSite)) : '', + width: 'width' in _image && typeof _image.width === 'number' ? _image.width : undefined, + height: 'height' in _image && typeof _image.height === 'number' ? _image.height : undefined, + }; + } + return { + url: '', + }; + } + + return { + url: '', + }; + }) + ); + + return { ...openGraph, ...(adaptedImages ? { images: adaptedImages } : {}) }; +}; diff --git a/src/utils/permalinks.ts b/src/utils/permalinks.ts new file mode 100644 index 0000000..4e3078d --- /dev/null +++ b/src/utils/permalinks.ts @@ -0,0 +1,134 @@ +import slugify from 'limax'; + +import { SITE, APP_BLOG } from 'astrowind:config'; + +import { trim } from '~/utils/utils'; + +export const trimSlash = (s: string) => trim(trim(s, '/')); +const createPath = (...params: string[]) => { + const paths = params + .map((el) => trimSlash(el)) + .filter((el) => !!el) + .join('/'); + return '/' + paths + (SITE.trailingSlash && paths ? '/' : ''); +}; + +const BASE_PATHNAME = SITE.base || '/'; + +export const cleanSlug = (text = '') => + trimSlash(text) + .split('/') + .map((slug) => slugify(slug)) + .join('/'); + +export const BLOG_BASE = cleanSlug(APP_BLOG?.list?.pathname); +export const CATEGORY_BASE = cleanSlug(APP_BLOG?.category?.pathname); +export const TAG_BASE = cleanSlug(APP_BLOG?.tag?.pathname) || 'tag'; + +export const POST_PERMALINK_PATTERN = trimSlash(APP_BLOG?.post?.permalink || `${BLOG_BASE}/%slug%`); + +/** */ +export const getCanonical = (path = ''): string | URL => { + const url = String(new URL(path, SITE.site)); + if (SITE.trailingSlash == false && path && url.endsWith('/')) { + return url.slice(0, -1); + } else if (SITE.trailingSlash == true && path && !url.endsWith('/')) { + return url + '/'; + } + return url; +}; + +/** */ +export const getPermalink = (slug = '', type = 'page'): string => { + let permalink: string; + + if ( + slug.startsWith('https://') || + slug.startsWith('http://') || + slug.startsWith('://') || + slug.startsWith('#') || + slug.startsWith('javascript:') + ) { + return slug; + } + + switch (type) { + case 'home': + permalink = getHomePermalink(); + break; + + case 'blog': + permalink = getBlogPermalink(); + break; + + case 'asset': + permalink = getAsset(slug); + break; + + case 'category': + permalink = createPath(CATEGORY_BASE, trimSlash(slug)); + break; + + case 'tag': + permalink = createPath(TAG_BASE, trimSlash(slug)); + break; + + case 'post': + permalink = createPath(trimSlash(slug)); + break; + + case 'page': + default: + permalink = createPath(slug); + break; + } + + return definitivePermalink(permalink); +}; + +/** */ +export const getHomePermalink = (): string => getPermalink('/'); + +/** */ +export const getBlogPermalink = (): string => getPermalink(BLOG_BASE); + +/** */ +export const getAsset = (path: string): string => + '/' + + [BASE_PATHNAME, path] + .map((el) => trimSlash(el)) + .filter((el) => !!el) + .join('/'); + +/** */ +const definitivePermalink = (permalink: string): string => createPath(BASE_PATHNAME, permalink); + +/** */ +export const applyGetPermalinks = (menu: object = {}) => { + if (Array.isArray(menu)) { + return menu.map((item) => applyGetPermalinks(item)); + } else if (typeof menu === 'object' && menu !== null) { + const obj = {}; + for (const key in menu) { + if (key === 'href') { + if (typeof menu[key] === 'string') { + obj[key] = getPermalink(menu[key]); + } else if (typeof menu[key] === 'object') { + if (menu[key].type === 'home') { + obj[key] = getHomePermalink(); + } else if (menu[key].type === 'blog') { + obj[key] = getBlogPermalink(); + } else if (menu[key].type === 'asset') { + obj[key] = getAsset(menu[key].url); + } else if (menu[key].url) { + obj[key] = getPermalink(menu[key].url, menu[key].type); + } + } + } else { + obj[key] = applyGetPermalinks(menu[key]); + } + } + return obj; + } + return menu; +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts new file mode 100644 index 0000000..e2ed559 --- /dev/null +++ b/src/utils/utils.ts @@ -0,0 +1,52 @@ +import { I18N } from 'astrowind:config'; + +export const formatter: Intl.DateTimeFormat = new Intl.DateTimeFormat(I18N?.language, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', +}); + +export const getFormattedDate = (date: Date): string => (date ? formatter.format(date) : ''); + +export const trim = (str = '', ch?: string) => { + let start = 0, + end = str.length || 0; + while (start < end && str[start] === ch) ++start; + while (end > start && str[end - 1] === ch) --end; + return start > 0 || end < str.length ? str.substring(start, end) : str; +}; + +// Function to format a number in thousands (K) or millions (M) format depending on its value +export const toUiAmount = (amount: number) => { + if (!amount) return 0; + + let value: string; + + if (amount >= 1000000000) { + const formattedNumber = (amount / 1000000000).toFixed(1); + if (Number(formattedNumber) === parseInt(formattedNumber)) { + value = parseInt(formattedNumber) + 'B'; + } else { + value = formattedNumber + 'B'; + } + } else if (amount >= 1000000) { + const formattedNumber = (amount / 1000000).toFixed(1); + if (Number(formattedNumber) === parseInt(formattedNumber)) { + value = parseInt(formattedNumber) + 'M'; + } else { + value = formattedNumber + 'M'; + } + } else if (amount >= 1000) { + const formattedNumber = (amount / 1000).toFixed(1); + if (Number(formattedNumber) === parseInt(formattedNumber)) { + value = parseInt(formattedNumber) + 'K'; + } else { + value = formattedNumber + 'K'; + } + } else { + value = Number(amount).toFixed(0); + } + + return value; +}; |
