This commit is contained in:
Adrian Woźniak 2023-08-09 16:35:37 +02:00
commit 335a84838f
27 changed files with 4441 additions and 514 deletions

1384
Cargo.lock generated

File diff suppressed because it is too large Load Diff

7
assets/build.js Normal file
View File

@ -0,0 +1,7 @@
(()=>{var c=Object.create;var i=Object.defineProperty;var d=Object.getOwnPropertyDescriptor;var a=Object.getOwnPropertyNames;var h=Object.getPrototypeOf,p=Object.prototype.hasOwnProperty;var u=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(e,s)=>(typeof require<"u"?require:e)[s]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+t+'" is not supported')});var m=(t,e,s,l)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of a(e))!p.call(t,o)&&o!==s&&i(t,o,{get:()=>e[o],enumerable:!(l=d(e,o))||l.enumerable});return t};var y=(t,e,s)=>(s=t!=null?c(h(t)):{},m(e||!t||!t.__esModule?i(s,"default",{value:t,enumerable:!0}):s,t));customElements.define("oswilno-price",class extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"})}connectedCallback(){let t=this.shadowRoot,e=parseInt(this.getAttribute("price"));isNaN(e)&&(e=0);let s=parseInt(this.getAttribute("multiplier")),l=e,o=0;isNaN(s)||(l=Math.floor(e/s),o=e%s);let r=this.getAttribute("currency")||"PLN";t.innerHTML=`<style>:host{display:block;}</style><div>${l}.${o>=10?o:o+"0"} ${r}</div>`}});var n=t=>{let e="";for(let l=0;l<document.styleSheets.length;l++){let o=document.styleSheets[l];for(let r=0;r<o.rules.length;r++)e+=o.rules[r].cssText}let s=new CSSStyleSheet;s.replaceSync(e),t.adoptedStyleSheets=[s]};customElements.define("oswilno-error",class extends HTMLElement{constructor(){super();let t=this.attachShadow({mode:"open"});t.innerHTML=`
<style>:host{display:block;}</style>
<div class="flex bg-red-100 rounded-lg p-4 mb-4 text-sm text-red-700">
<slot></slot>
<div>
`,n(t)}});import("https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.js");document.body.addEventListener("htmx:beforeOnLoad",function(t){let e=t.detail.xhr.status;(e===422||e===400)&&(t.detail.shouldSwap=!0,t.detail.isError=!1)});})();
//# sourceMappingURL=build.js.map

7
assets/build.js.map Normal file
View File

@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../crates/web-assets/assets/elements/oswilno-price.js", "../crates/web-assets/assets/css.js", "../crates/web-assets/assets/elements/oswilno-error.js", "../crates/web-assets/assets/app.js"],
"sourcesContent": ["customElements.define('oswilno-price', class extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tthis.attachShadow({ mode: 'open' });\n\t}\n\tconnectedCallback() {\n\t\tlet shadow = this.shadowRoot;\n\t\tlet price = parseInt(this.getAttribute('price'));\n\t\tif (isNaN(price)) price = 0;\n\t\tconst multiplier = parseInt(this.getAttribute('multiplier'));\n\t\tlet major = price;\n\t\tlet minor = 0;\n\t\tif (!isNaN(multiplier)) {\n\t\t\tmajor = Math.floor(price / multiplier);\n\t\t\tminor = price % multiplier;\n\t\t}\n\t\tconst currency = this.getAttribute('currency') || 'PLN';\n\t\tshadow.innerHTML = `<style>:host{display:block;}</style><div>${major}.${minor >= 10 ? minor : minor + '0'} ${ currency }</div>`;\n\t}\n});\n", "export const copyCss = (shadow) => {\n\tlet css = '';\n\tfor (let i = 0; i < document.styleSheets.length; i++) {\n\t\tconst styleSheet = document.styleSheets[i];\n\t\tfor (let j = 0; j < styleSheet.rules.length; j++) {\n\t\t\tcss += styleSheet.rules[j].cssText;\t\n\t\t}\n\t}\n\tconst sheet = new CSSStyleSheet();\n\tsheet.replaceSync(css);\n\tshadow.adoptedStyleSheets = [sheet];\n};\n", "import { copyCss } from \"../css.js\";\n\ncustomElements.define('oswilno-error', class extends HTMLElement {\n\tconstructor() {\n\t\tsuper();\n\t\tconst shadow = this.attachShadow({ mode: 'open' });\n\t\tshadow.innerHTML = `\n\t\t\t<style>:host{display:block;}</style>\n\t\t\t<div class=\"flex bg-red-100 rounded-lg p-4 mb-4 text-sm text-red-700\">\n\t\t\t\t<slot></slot>\n\t\t\t<div>\n\t\t`;\n\t\tcopyCss(shadow);\n\t}\n});\n", "import './elements/oswilno-price.js';\nimport './elements/oswilno-error.js';\nimport(\"https://unpkg.com/htmx.org@1.9.4/dist/htmx.min.js\");\n\ndocument.body.addEventListener('htmx:beforeOnLoad', function (evt) {\n\tconst status = evt.detail.xhr.status;\n\tif (status === 422 || status === 400) {\n\t\tevt.detail.shouldSwap = true;\n\t\tevt.detail.isError = false;\n\t}\n});\n\n"],
"mappings": "0sBAAA,eAAe,OAAO,gBAAiB,cAAc,WAAY,CAChE,aAAc,CACb,MAAM,EACN,KAAK,aAAa,CAAE,KAAM,MAAO,CAAC,CACnC,CACA,mBAAoB,CACnB,IAAIA,EAAS,KAAK,WACdC,EAAQ,SAAS,KAAK,aAAa,OAAO,CAAC,EAC3C,MAAMA,CAAK,IAAGA,EAAQ,GAC1B,IAAMC,EAAa,SAAS,KAAK,aAAa,YAAY,CAAC,EACvDC,EAAQF,EACRG,EAAQ,EACP,MAAMF,CAAU,IACpBC,EAAQ,KAAK,MAAMF,EAAQC,CAAU,EACrCE,EAAQH,EAAQC,GAEjB,IAAMG,EAAW,KAAK,aAAa,UAAU,GAAK,MAClDL,EAAO,UAAY,4CAA4CG,CAAK,IAAIC,GAAS,GAAKA,EAAQA,EAAQ,GAAG,IAAKC,CAAS,QACxH,CACD,CAAC,ECnBM,IAAMC,EAAWC,GAAW,CAClC,IAAIC,EAAM,GACV,QAASC,EAAI,EAAGA,EAAI,SAAS,YAAY,OAAQA,IAAK,CACrD,IAAMC,EAAa,SAAS,YAAYD,CAAC,EACzC,QAASE,EAAI,EAAGA,EAAID,EAAW,MAAM,OAAQC,IAC5CH,GAAOE,EAAW,MAAMC,CAAC,EAAE,OAE7B,CACA,IAAMC,EAAQ,IAAI,cAClBA,EAAM,YAAYJ,CAAG,EACrBD,EAAO,mBAAqB,CAACK,CAAK,CACnC,ECTA,eAAe,OAAO,gBAAiB,cAAc,WAAY,CAChE,aAAc,CACb,MAAM,EACN,IAAMC,EAAS,KAAK,aAAa,CAAE,KAAM,MAAO,CAAC,EACjDA,EAAO,UAAY;AAAA;AAAA;AAAA;AAAA;AAAA,IAMnBC,EAAQD,CAAM,CACf,CACD,CAAC,ECZD,OAAO,mDAAmD,EAE1D,SAAS,KAAK,iBAAiB,oBAAqB,SAAUE,EAAK,CAClE,IAAMC,EAASD,EAAI,OAAO,IAAI,QAC1BC,IAAW,KAAOA,IAAW,OAChCD,EAAI,OAAO,WAAa,GACxBA,EAAI,OAAO,QAAU,GAEvB,CAAC",
"names": ["shadow", "price", "multiplier", "major", "minor", "currency", "copyCss", "shadow", "css", "i", "styleSheet", "j", "sheet", "shadow", "copyCss", "evt", "status"]
}

847
assets/style.css Normal file
View File

@ -0,0 +1,847 @@
/*
! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
*/
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: #e5e7eb;
/* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
*/
html {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
-o-tab-size: 4;
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
font-variation-settings: normal;
/* 6 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font family by default.
2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%;
/* 1 */
font-weight: inherit;
/* 1 */
line-height: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
/* 1 */
background-color: transparent;
/* 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::-moz-placeholder, textarea::-moz-placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
[type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-color: #fff;
border-color: #6b7280;
border-width: 1px;
border-radius: 0px;
padding-top: 0.5rem;
padding-right: 0.75rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
font-size: 1rem;
line-height: 1.5rem;
--tw-shadow: 0 0 #0000;
}
[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: #2563eb;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
border-color: #2563eb;
}
input::-moz-placeholder, textarea::-moz-placeholder {
color: #6b7280;
opacity: 1;
}
input::placeholder,textarea::placeholder {
color: #6b7280;
opacity: 1;
}
::-webkit-datetime-edit-fields-wrapper {
padding: 0;
}
::-webkit-date-and-time-value {
min-height: 1.5em;
}
::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
padding-top: 0;
padding-bottom: 0;
}
select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
[multiple],[size]:where(select:not([size="1"])) {
background-image: initial;
background-position: initial;
background-repeat: unset;
background-size: initial;
padding-right: 0.75rem;
-webkit-print-color-adjust: unset;
print-color-adjust: unset;
}
[type='checkbox'],[type='radio'] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
padding: 0;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
display: inline-block;
vertical-align: middle;
background-origin: border-box;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
flex-shrink: 0;
height: 1rem;
width: 1rem;
color: #2563eb;
background-color: #fff;
border-color: #6b7280;
border-width: 1px;
--tw-shadow: 0 0 #0000;
}
[type='checkbox'] {
border-radius: 0px;
}
[type='radio'] {
border-radius: 100%;
}
[type='checkbox']:focus,[type='radio']:focus {
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
--tw-ring-offset-width: 2px;
--tw-ring-offset-color: #fff;
--tw-ring-color: #2563eb;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
[type='checkbox']:checked,[type='radio']:checked {
border-color: transparent;
background-color: currentColor;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat;
}
[type='checkbox']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
[type='radio']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
}
[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
border-color: transparent;
background-color: currentColor;
}
[type='checkbox']:indeterminate {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");
border-color: transparent;
background-color: currentColor;
background-size: 100% 100%;
background-position: center;
background-repeat: no-repeat;
}
[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
border-color: transparent;
background-color: currentColor;
}
[type='file'] {
background: unset;
border-color: inherit;
border-width: 0;
border-radius: 0;
padding: 0;
font-size: unset;
line-height: inherit;
}
[type='file']:focus {
outline: 1px solid ButtonText;
outline: 1px auto -webkit-focus-ring-color;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mb-6 {
margin-bottom: 1.5rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.block {
display: block;
}
.flex {
display: flex;
}
.min-h-screen {
min-height: 100vh;
}
.w-32 {
width: 8rem;
}
.w-full {
width: 100%;
}
.max-w-md {
max-width: 28rem;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.rounded-lg {
border-radius: 0.5rem;
}
.border {
border-width: 1px;
}
.bg-cyan-600 {
--tw-bg-opacity: 1;
background-color: rgb(8 145 178 / var(--tw-bg-opacity));
}
.bg-red-100 {
--tw-bg-opacity: 1;
background-color: rgb(254 226 226 / var(--tw-bg-opacity));
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.p-4 {
padding: 1rem;
}
.p-6 {
padding: 1.5rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity));
}
.text-red-700 {
--tw-text-opacity: 1;
color: rgb(185 28 28 / var(--tw-text-opacity));
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.shadow {
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.shadow-lg {
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
}
.focus\:ring-2:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.focus\:ring-cyan-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(6 182 212 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}

View File

@ -11,7 +11,7 @@ path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
oswilno-contract = { path = "../oswilno-contract" }
sea-orm = { version = "0.11", features = ["runtime-actix-rustls", "sqlx-postgres", "postgres-array", "sqlx"] }
sea-orm = { version = "0.11.3", features = ["runtime-actix-rustls", "sqlx-postgres", "postgres-array", "sqlx"] }
tokio = { version = "1.29.1", features = ["full"] }
[dependencies.sea-orm-migration]

View File

@ -0,0 +1,36 @@
use crate::m20220101_000001_create_table::Account;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
let table = Table::alter()
.table(Account::Accounts)
.add_column(
ColumnDef::new(Account::Email)
.string()
.unique_key()
.default("filler@example.com")
.not_null(),
)
.to_owned();
m.alter_table(table).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.alter_table(
Table::alter()
.table(Account::Accounts)
.drop_column(Account::Email)
.to_owned(),
)
.await?;
Ok(())
}
}

View File

@ -15,6 +15,8 @@ oswilno-config = { path = "../oswilno-config" }
oswilno-parking-space = { path = "../oswilno-parking-space" }
oswilno-session = { path = "../oswilno-session" }
oswilno-view = { path = "../oswilno-view" }
redis = { version = "0.17" }
redis-async-pool = "0.2.4"
sea-orm = { version = "0.11", features = ["postgres-array", "runtime-actix-rustls", "sqlx-postgres"] }
serde = { version = "1.0.175", features = ["derive"] }
serde_json = "1.0.103"

View File

@ -29,8 +29,21 @@ async fn main() -> std::io::Result<()> {
.idle_timeout(Duration::from_secs(8))
.sqlx_logging(true);
let conn: sea_orm::DatabaseConnection = sea_orm::Database::connect(db_opts).await.unwrap();
let redis = {
use redis_async_pool::{RedisConnectionManager, RedisPool};
RedisPool::new(
RedisConnectionManager::new(
redis::Client::open("redis://localhost:6379").expect("Fail to connect to redis"),
true,
None,
),
5,
)
};
let mut l10n = oswilno_view::TranslationStorage::new();
let session_config = oswilno_session::SessionConfigurator::new();
let session_config = oswilno_session::SessionConfigurator::new(redis.clone());
session_config.translations(&mut l10n);
HttpServer::new(move || {
@ -39,6 +52,7 @@ async fn main() -> std::io::Result<()> {
.wrap(middleware::Logger::default())
.wrap(session_config.factory())
.app_data(Data::new(conn.clone()))
.app_data(Data::new(redis.clone()))
.app_data(Data::new(l10n.clone()))
.configure(oswilno_parking_space::mount)
.configure(oswilno_admin::mount)

View File

@ -11,12 +11,15 @@ argon2 = "0.5.1"
askama = { version = "0.12.0", features = ["serde", "with-actix-web", "comrak", "mime"] }
askama_actix = { version = "0.14.0" }
autometrics = { version = "0.5.0", features = ["tracing", "tracing-subscriber", "thiserror"] }
bincode = "1.3.3"
futures = { version = "0.3.28", features = ["thread-pool"] }
garde = { version = "0.14.0", features = ["derive"] }
jsonwebtoken = "8.3.0"
oswilno-contract = { path = "../oswilno-contract" }
oswilno-view = { path = "../oswilno-view" }
rand = "0.8.5"
redis = { version = "0.17" }
redis-async-pool = "0.2.4"
ring = "0.16.20"
sea-orm = { version = "0.11", features = ["postgres-array", "runtime-actix-rustls", "sqlx-postgres", "macros", "sqlx"] }
serde = { version = "1.0.180", features = ["derive"] }

View File

@ -4,14 +4,14 @@ use std::sync::Arc;
use actix_jwt_authc::*;
use actix_web::web::{Data, Form, ServiceConfig};
use actix_web::{get, post, HttpResponse};
use askama_actix::Template;
use askama_actix::{Template, TemplateToResponse as _};
use autometrics::autometrics;
use futures::channel::{mpsc, mpsc::Sender};
use futures::stream::Stream;
use futures::SinkExt;
use garde::Validate;
use jsonwebtoken::*;
use oswilno_view::{Lang, TranslationStorage};
use oswilno_view::{Errors, Lang, TranslationStorage};
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair};
use sea_orm::DatabaseConnection;
@ -115,7 +115,7 @@ impl SessionConfigurator {
.done();
}
pub fn new() -> Self {
pub fn new(redis: redis_async_pool::RedisPool) -> Self {
let jwt_ttl = JWTTtl(31.days());
let jwt_signing_keys = JwtSigningKeys::generate().unwrap();
let validator = Validation::new(JWT_SIGNING_ALGO);
@ -125,7 +125,7 @@ impl SessionConfigurator {
jwt_validator: validator,
jwt_session_key: None,
};
let (invalidated_jwts_store, stream) = InvalidatedJWTStore::new_with_stream();
let (invalidated_jwts_store, stream) = InvalidatedJWTStore::new_with_stream(redis);
let auth_middleware_factory =
AuthenticateMiddlewareFactory::<Claims>::new(stream, auth_middleware_settings.clone());
@ -144,6 +144,7 @@ struct SignInTemplate {
form: SignInPayload,
lang: Lang,
t: Arc<TranslationStorage>,
errors: Errors,
}
#[derive(Template)]
@ -152,20 +153,13 @@ struct SignInPartialTemplate {
form: SignInPayload,
lang: Lang,
t: Arc<TranslationStorage>,
errors: Errors,
}
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Default)]
pub struct SignInPayload {
#[serde(skip, default)]
errors: Vec<String>,
login: String,
#[serde(skip, default)]
login_errors: Vec<String>,
password: String,
#[serde(skip, default)]
password_errors: Vec<String>,
}
#[get("/login")]
@ -174,6 +168,7 @@ async fn login_view(t: Data<TranslationStorage>) -> SignInTemplate {
form: SignInPayload::default(),
lang: Lang::Pl,
t: t.into_inner(),
errors: Errors::default(),
}
}
#[get("/p/login")]
@ -182,6 +177,7 @@ async fn login_partial_view(t: Data<TranslationStorage>) -> SignInPartialTemplat
form: SignInPayload::default(),
lang: Lang::Pl,
t: t.into_inner(),
errors: Errors::default(),
}
}
@ -194,25 +190,37 @@ async fn login(
payload: Form<SignInPayload>,
t: Data<oswilno_view::TranslationStorage>,
lang: Lang,
redis: Data<redis_async_pool::RedisPool>,
) -> Result<HttpResponse, Error> {
let t = t.into_inner();
match login_inner(jwt_encoding_key, jwt_ttl, db, payload).await {
let mut errors = Errors::default();
match login_inner(
jwt_encoding_key,
jwt_ttl,
payload.into_inner(),
db.into_inner(),
redis.into_inner(),
&mut errors,
)
.await
{
Ok(res) => Ok(HttpResponse::Ok().json(res)),
Err(form) => {
Ok(HttpResponse::Ok().body(SignInPartialTemplate { form, lang, t }.render().unwrap()))
}
Err(form) => Ok(HttpResponse::Ok().body(
(SignInPartialTemplate { form, lang, t, errors })
.render()
.unwrap(),
)),
}
}
async fn login_inner(
jwt_encoding_key: Data<EncodingKey>,
jwt_ttl: Data<JWTTtl>,
db: Data<DatabaseConnection>,
payload: Form<SignInPayload>,
payload: SignInPayload,
db: Arc<DatabaseConnection>,
redis: Arc<redis_async_pool::RedisPool>,
errors: &mut Errors,
) -> Result<LoginResponse, SignInPayload> {
let db = db.into_inner();
let mut payload = payload.into_inner();
let iat = OffsetDateTime::now_utc().unix_timestamp() as usize;
let expires_at = OffsetDateTime::now_utc().add(jwt_ttl.0);
let exp = expires_at.unix_timestamp() as usize;
@ -225,17 +233,17 @@ async fn login_inner(
{
Ok(Some(a)) => a,
Ok(None) => {
payload.errors.push("Bad credentials".into());
errors.push_global("Bad credentials");
return Err(payload);
}
Err(e) => {
tracing::warn!("Failed to find account: {e}");
payload.errors.push("Bad credentials".into());
errors.push_global("Bad credentials");
return Err(payload);
}
};
if hashing::verify(account.pass_hash.as_str(), payload.password.as_str()).is_err() {
payload.errors.push("Bad credentials".into());
errors.push_global("Bad credentials");
return Err(payload);
}
@ -252,9 +260,29 @@ async fn login_inner(
&jwt_encoding_key,
)
.map_err(|_| {
payload.errors.push("Bad credentials".into());
payload
errors.push_global("Bad credentials");
payload.clone()
})?;
let Ok(bin_value) = bincode::serialize(&jwt_claims) else {
errors.push_global("Failed to sign in. Please try later");
return Err(payload.clone());
};
{
use redis::AsyncCommands;
let Ok(mut conn) = redis.get().await else {
errors.push_global("Failed to sign in. Please try later");
return Err(payload);
};
if let Err(e) = conn
.set::<'_, _, _, String>(jwt_claims.jwt_id.as_bytes(), bin_value)
.await
{
tracing::warn!("Failed to set sign-in claims in redis: {e}");
errors.push_global("Failed to sign in. Please try later");
return Err(payload);
}
}
Ok(LoginResponse {
bearer_token: jwt_token,
claims: jwt_claims,
@ -465,12 +493,15 @@ async fn register_internal(
#[derive(Clone)]
struct InvalidatedJWTStore {
// store: Arc<DashSet<JWT>>,
redis: redis_async_pool::RedisPool,
tx: Arc<Mutex<Sender<InvalidatedTokensEvent>>>,
}
impl InvalidatedJWTStore {
/// Returns a [InvalidatedJWTStore] with a Stream of [InvalidatedTokensEvent]s
fn new_with_stream() -> (
fn new_with_stream(
redis: redis_async_pool::RedisPool,
) -> (
InvalidatedJWTStore,
impl Stream<Item = InvalidatedTokensEvent>,
) {
@ -480,6 +511,7 @@ impl InvalidatedJWTStore {
(
InvalidatedJWTStore {
// store: invalidated,
redis,
tx: tx_to_hold,
},
rx,

View File

@ -1,20 +1,19 @@
<section id="main-view" class="min-h-screen flex items-center justify-center">
<section class="max-w-md w-full p-6 bg-white rounded-lg shadow-lg">
{% for error in form.errors %}
{% for error in errors.global() %}
<oswilno-error>{{error|t(lang,t)}}</oswilno-error>
{% endfor %}
<form hx-post="/login" hx-target="#main-view">
<div class="mb-4">
<label for="login" class="block mb-2 text-sm text-gray-600">Login</label>
<input id="login" name="login" value="{{ form.login }}" class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" />
{% for error in form.login_errors %}
<oswilno-error>{{error|t(lang,t)}}</oswilno-error>
{% for error in errors.field("login") %}
{% endfor %}
</div>
<div class="mb-4">
<label for="password" class="block mb-2 text-sm text-gray-600">Password</label>
<input id="password" name="password" type="password" value="" class="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-cyan-500" />
{% for error in form.password_errors %}
{% for error in errors.field("password") %}
<oswilno-error>{{error|t(lang,t)}}</oswilno-error>
{% endfor %}
</div>

View File

@ -0,0 +1,80 @@
use actix_web::error::Error;
use actix_web::HttpRequest;
use std::future::Future;
use std::pin::Pin;
use std::str::FromStr;
use std::task::{Context, Poll};
#[derive(Clone, Copy, Hash, PartialEq, Eq, Debug, Default)]
pub enum Lang {
#[default]
Pl,
En,
}
impl FromStr for Lang {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
Ok(match value {
"pl" => Self::Pl,
"en" => Self::En,
_ => return Err(()),
})
}
}
pub struct ExtractLangFuture {
req: Option<HttpRequest>,
}
impl Future for ExtractLangFuture {
type Output = Result<Lang, Error>;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.get_mut();
let default_lang = Lang::Pl;
let Some(req) = this.req.as_ref() else {
return Poll::Ready(Ok(default_lang));
};
let Some(v) = req.headers().get("Accept-Language") else {
return Poll::Ready(Ok(default_lang));
};
tracing::trace!("Accept-Language value -> {v:?}");
let Ok(value) = v.to_str() else {
return Poll::Ready(Ok(default_lang));
};
tracing::trace!("Accept-Language str {value:?}");
for lang in value.split(',') {
let l = lang.trim();
let Some(lang) = l.split(';').next() else {
tracing::trace!("Failed to split {l:?}");
continue;
};
let lang = lang.trim();
tracing::trace!("trying lang {lang:?}");
if let Ok(lang) = lang.parse::<Lang>() {
tracing::trace!("found lang {lang:?}");
return Poll::Ready(Ok(lang));
}
}
Poll::Ready(Ok(Lang::Pl))
}
}
impl actix_web::FromRequest for Lang {
type Error = actix_web::error::Error;
type Future = ExtractLangFuture;
fn from_request(
req: &actix_web::HttpRequest,
_payload: &mut actix_web::dev::Payload,
) -> Self::Future {
ExtractLangFuture {
req: Some(req.clone()),
}
}
}

19
crates/web/Cargo.toml Normal file
View File

@ -0,0 +1,19 @@
[package]
name = "web"
version = "0.1.0"
edition = "2021"
[dependencies]
bincode = { version = "1.3.3", features = [] }
console_error_panic_hook = "0.1.7"
contract = { path = "../contract" }
serde = { version = "*", features = ['derive'] }
serde-wasm-bindgen = "*"
serde_json = { version = "*" }
sycamore = { version = "0.8.2", features = ['suspense'] }
sycamore-router = { version = "0.8" }
tracing = { version = "*" }
tracing-subscriber = { version = "*" }
tracing-subscriber-wasm = { version = "0.1.0" }
wasm_request = { version = "0.1.1" }
web-sys = { version = "0.3.61", features = ['Headers'] }

7
crates/web/package.json Normal file
View File

@ -0,0 +1,7 @@
{
"dependencies": {
"@tailwindcss/line-clamp": "^0.4.4",
"tailwind": "^4.0.0",
"update-browserslist-db": "^1.0.11"
}
}

1969
crates/web/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
use sycamore::prelude::*;
#[component]
pub fn Footer<G: Html>(cx: Scope) -> View<G> {
view! {
cx,
div(class="w-full h-64 xl:h-16 relative") {
footer(class="w-full absolute bottom-0") {
div(class="container mx-auto px-4") {
div(class="items-center xl:justify-between flex flex-wrap -mx-4") {
div(class="px-4 relative w-full xl:w-6/12 w-full sm:w-full") {
div(class="text-sm text-gray-200 text-center xl:text-left py-6") {
"Copyright © 2021"
a(href="https://ita-prog.pl", target="_blank", class="text-blueGray-100 font-semibold ml-1") {
"ITA Prog"
}
". All rights reserved."
}
}
div(class="px-4 relative w-full xl:w-6/12 w-full sm:w-full") {
ul(class="justify-center xl:justify-end mx-auto flex flex-wrap list-none pl-0 mb-0") {
li() {a(target = "__blank") {}}
li() {a(target = "__blank") {}}
li() {a(target = "__blank") {}}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,64 @@
use sycamore::prelude::*;
#[component]
pub fn Header<G: Html>(cx: Scope) -> View<G> {
view!(cx, div(class = "px-4 py-5 flex-auto ct-docs-frame") {
div(class = "relative flex flex-wrap justify-center bg-blueGray-100") {
div(class = "w-full h-16 relative") {
nav(class = "absolute w-full z-50 flex flex-wrap items-center justify-between px-2 py-3 mb-3 lg:bg-transparent bg-white") {
div(class = "flex container mx-auto flex flex-wrap items-center justify-between px-0 lg:px-4") {
a(
class = "text-sm font-bold leading-relaxed inline-flex items-center mr-4 py-2 whitespace-nowrap uppercase text-blueGray-500",
href = "/"
) {
img(class = "rounded-full mr-2", style = "width: 30px") {}
span() { "OS Wilno" }
button(class="ml-auto cursor-pointer text-xl leading-none px-3 py-1 border border-solid border-blueGray-400 rounded bg-transparent block outline-none focus:outline-none text-blueGray-300 lg:hidden", type="button") {
i(class="fas fa-bars")
}
div(class="items-center w-full lg:flex lg:w-auto flex-grow duration-300 transition-all ease-in-out hidden") {
ul(class="lg:items-center lg:ml-auto flex flex-wrap list-none pl-0 mb-0 flex flex-col list-none pl-0 mb-0 lg:flex-row") {
li {
a(class="hover:opacity-75 px-3 py-4 lg:py-2 flex items-center text-xs uppercase font-bold transition-all duration-150 ease-in-out") {
"Usługi"
}
}
li {
a(class="hover:opacity-75 px-3 py-4 lg:py-2 flex items-center text-xs uppercase font-bold transition-all duration-150 ease-in-out") {
"Garaż"
}
}
li(class = "relative") {
a(class="hover:opacity-75 px-3 py-4 lg:py-2 flex items-center text-xs uppercase font-bold transition-all duration-150 ease-in-out text-blueGray-800", href="") {
"Demo Pages"
i(class="ml-1 fas fa-caret-down transition-all duration-200 ease-in-out transform")
}
div(class="hidden z-50") {
div(class="origin-top-right bg-white text-base float-left p-2 border list-none text-left rounded-lg shadow-lg min-w-48 transition-all duration-100 ease-in-out transform scale-95 opacity-0 absolute") {
span(class="uppercase font-bold text-xs px-3 pt-4 block w-full whitespace-nowrap bg-transparent text-blueGray-400") {
"Group 1"
}
a(class="text-sm px-3 py-2 block w-full whitespace-nowrap bg-transparent hover:bg-blueGray-100 rounded transition-all duration-100") {
"Demo page 1"
}
a(class="text-sm px-3 py-2 block w-full whitespace-nowrap bg-transparent hover:bg-blueGray-100 rounded transition-all duration-100") {
"Demo page 2"
}
span(class="uppercase font-bold text-xs px-3 pt-4 block w-full whitespace-nowrap bg-transparent text-blueGray-400") { "Group 2" }
}
}
}
li(class = "relative") {
a(class="hover:opacity-75 px-3 py-4 lg:py-2 flex items-center text-xs uppercase font-bold transition-all duration-150 ease-in-out", href = "/account") {
"Account"
}
}
}
}
}
}
}
}
}
})
}

View File

@ -0,0 +1,7 @@
mod card;
mod footer;
mod header;
pub use card::*;
pub use footer::*;
pub use header::*;

23
crates/web/src/main.rs Normal file
View File

@ -0,0 +1,23 @@
mod components;
mod pages;
use pages::AppRoutes;
use sycamore::prelude::*;
use tracing_subscriber_wasm::MakeConsoleWriter;
fn main() {
console_error_panic_hook::set_once();
tracing_subscriber::fmt::fmt()
.with_ansi(false)
.with_writer(MakeConsoleWriter::default().map_trace_level_to(tracing::Level::DEBUG))
.without_time()
.init();
sycamore::render(|cx| {
view! { cx,
main() {
AppRoutes()
}
}
});
}

View File

View File

@ -0,0 +1,7 @@
pub mod details;
pub mod register;
pub mod sign_in;
use details::*;
use register::*;
use sign_in::*;

View File

View File

@ -0,0 +1,105 @@
use sycamore::prelude::*;
#[component]
pub fn SignIn<G: Html>(cx: Scope) -> View<G> {
let name = create_signal(cx, "".to_string());
let pass = create_signal(cx, "".to_string());
let agree = create_signal(cx, false);
view!(
cx,
div(class="flex content-center items-center justify-center h-full") {
div(class="w-full lg:w-5/12 px-4") {
div(class="relative flex flex-col w-full mb-6 shadow-lg rounded-lg bg-white") {
div(class="mb-0 px-6 py-6") {
div(class="text-center mb-3") {
h6(class="text-blueGray-500 text-sm font-bold") { "Sign up with" }
}
div(class="text-center") {
a(
class="inline-block outline-none focus:outline-none align-middle transition-all duration-150 ease-in-out uppercase border border-solid font-bold last:mr-0 mr-2 text-white bg-github-regular border-github-regular active:bg-github-active active:border-github-active text-xs px-3 py-2 shadow hover:shadow-md rounded-md",
style="background-color: rgba(34, 34, 34, var(--tw-bg-opacity))"
) {
i( class="mr-1 fab fa-solid fa-github")
" github"
}
a(
class="inline-block outline-none focus:outline-none align-middle transition-all duration-150 ease-in-out uppercase border border-solid font-bold last:mr-0 mr-2 text-white bg-facebook-regular border-facebook-regular active:bg-facebook-active active:border-facebook-active text-xs px-3 py-2 shadow hover:shadow-md rounded-md",
style="background-color: rgba(59, 89, 153, var(--tw-bg-opacity))"
) {
i(class="mr-1 fab fa-solid fa-facebook")
" facebook"
}
}
hr(class="mt-6 border-b-1 border-blueGray-200")
}
div(class="flex-auto px-4 lg:px-10 py-10 pt-0") {
div(class="text-blueGray-500 text-center mb-3 font-bold") {
small { "Or sign up with credentials" }
}
form(data-bitwarden-watching="1") {
div(class="relative w-full") {
label(
class="block uppercase text-blueGray-500 text-xs font-bold mb-2 ml-1"
) {
"Name"
}
div(class="mb-3 pt-0") {
input(
placeholder="Name",
type="text",
bind:value=name,
class="border-blueGray-300 px-3 py-2 text-sm w-full placeholder-blueGray-200 text-blueGray-700 relative bg-white rounded-md outline-none focus:ring focus:ring-lightBlue-500 focus:ring-1 focus:border-lightBlue-500 border border-solid transition duration-200"
)
}
}
div(
class="relative w-full"
) {
label(
class="block uppercase text-blueGray-500 text-xs font-bold mb-2 ml-1"
) {
"Password"
}
div(class="mb-3 pt-0") {
input(
placeholder="Password",
type="password",
bind:value=pass,
class="border-blueGray-300 px-3 py-2 text-sm w-full placeholder-blueGray-200 text-blueGray-700 relative bg-white rounded-md outline-none focus:ring focus:ring-lightBlue-500 focus:ring-1 focus:border-lightBlue-500 border border-solid transition duration-200 "
)
}
}
div(
class="mt-2 inline-block"
) {
label(
class="inline-flex items-center cursor-pointer"
) {
input(
type="checkbox",
bind:checked=agree,
class="form-checkbox appearance-none ml-1 w-5 h-5 ease-linear transition-all duration-150 border border-blueGray-300 rounded checked:bg-blueGray-700 checked:border-blueGray-700 focus:border-blueGray-300"
)
span(class="ml-2 text-sm font-semibold text-blueGray-500") {
"I agree with the Privacy Policy"
}
}
}
div(class="text-center mt-5") {
a(
href="/register",
class="inline-block outline-none focus:outline-none align-middle transition-all duration-150 ease-in-out uppercase border border-solid font-bold last:mr-0 mr-2 text-white bg-blueGray-800 border-blueGray-800 active:bg-blueGray-900 active:border-blueGray-900 text-sm px-6 py-2 shadow hover:shadow-lg rounded-md w-full text-center",
style="background-color: rgba(30, 41, 59, var(--tw-bg-opacity))"
) {
"Create account"
}
}
}
}
}
}
}
)
}

View File

@ -0,0 +1,103 @@
use contract::LocalBusinessItem;
use sycamore::prelude::*;
#[derive(Prop)]
pub struct LocalBusinessCardProps {
name: String,
desc: String,
items: Vec<LocalBusinessItem>,
}
#[component]
pub fn LocalBusinessCard<G: Html>(cx: Scope<'_>, props: LocalBusinessCardProps) -> View<G> {
let items = create_signal(cx, props.items);
view! {
cx,
div(class = "px-4 relative w-full md:w-4/12") {
div(class = "mb-6 text-center shadow-lg rounded-lg relative flex flex-col bg-white p-6 w-full mb-6") {
div(class = "py-6 flex-auto") {
div(class = "shadow-lg mt-6 rounded-full my-6 mx-auto w-100-px p-6 bg-white") {
img(class = "mx-auto", src = "data:image/png;base64,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") {}
}
h4(class = "text-2xl font-bold leading-tight mt-0 mb-2") { (props.name) }
p(class="text-blueGray-500 px-8 line-clamp-2") { (props.desc) }
div(class = "flex justify-center mt-8 mb-2 text-blueGray-400") {
div(class = "flex items-center") {
Indexed(
iterable=items,
view=|cx, item| view! { cx,
BusinessItem(
url = item.picture_url,
name = item.name
)
}
)
}
}
Rating(rating = 37)
}
}
}
}
}
#[derive(Prop)]
pub struct BusinessItemProps {
name: String,
url: String,
}
#[component]
pub fn BusinessItem<G: Html>(cx: Scope<'_>, props: BusinessItemProps) -> View<G> {
view!(
cx,
a(
class="text-white bg-blueGray-500 inline-flex items-center justify-center shadow-lg rounded rounded-full relative border-2 border-white -ml-4 hover:z-1 w-10 h-10",
title = props.name
) {
img(class="rounded-full w-full", src=(props.url)) {}
div(class = "hidden") {
div(class = "border-0 mb-3 block z-50 font-normal leading-normal text-sm text-left no-underline break-words rounded") {
div(class = "py-1 px-2 text-center rounded text-white bg-black") { "Photo" }
}
}
}
)
}
#[derive(Prop)]
struct RatingProps {
rating: u8,
}
#[component]
fn Rating<G: Html>(cx: Scope, props: RatingProps) -> View<G> {
let rating = props.rating;
let has_half = rating % 10 != 0;
let full = rating / 10;
let empty = if has_half { 4 - full } else { 5 - full };
let full_it = create_signal(cx, (0..full).collect::<Vec<_>>());
let empty_it = create_signal(cx, (0..empty).collect::<Vec<_>>());
view! {
cx,
div(class="w-full") {
div(class="text-orange-500") {
Indexed(
iterable = full_it,
view = |cx, _| view!(cx, i(class="mr-1 fas fa-star"))
)
(if has_half {
view!(cx, i(class="mr-1 fas fa-star-half-alt"))
} else {
view!(cx, )
})
Indexed(
iterable = empty_it,
view = |cx, _| view!(cx, i(class="mr-1 fa fa-regular fa-star"))
)
a(href="https://www.creative-tim.com", class="inline text-sm ml-1 text-blueGray-700 hover:text-blueGray-500") { "76 customer reviews" }
}
}
}
}

View File

@ -0,0 +1,89 @@
use contract::businesses::BusinessList;
use contract::*;
use sycamore::prelude::*;
use sycamore::suspense::Suspense;
use tracing::info;
use super::local_business::LocalBusinessCard;
#[component]
async fn LocalBusinessesList<G: Html>(cx: Scope<'_>) -> View<G> {
let req = wasm_request::get_options::<Vec<LocalBusiness>>(
"/api/local-businesses",
wasm_request::Method::Get,
Some({
let h = web_sys::Headers::new().unwrap();
h.append("Accept", "application/json").unwrap();
h
}),
None,
);
let payload = wasm_request::request(req).await.unwrap_or_default();
let page = serde_wasm_bindgen::from_value::<BusinessList>(payload).unwrap_or_default();
info!("{page:#?}");
let businesses = {
let businesses = page.businesses.clone();
create_signal(cx, businesses)
};
let search = create_signal(cx, "".to_string());
let visible = create_memo(cx, || {
let s = &*search.get();
let s = s.trim();
if s.is_empty() {
let b = businesses.get();
b.as_slice().to_vec()
} else {
businesses
.get()
.iter()
.filter(|b| b.name.contains(s) || b.description.contains(s))
.cloned()
.collect::<Vec<_>>()
}
});
view! {
cx,
div(class = "container mx-auto px-4") {
div(class = "items-center w-full lg:flex lg:w-auto flex-grow duration-300 transition-all ease-in-out lg:h-auto-important hidden") {
form(class = "flex flex-row flex-wrap items-center ml-auto mr-3 mt-4") {
div(class = "hover:opacity-75 px-3 py-4 lg:py-2 flex items-center text-xs uppercase font-bold transition-all duration-150 ease-in-out text-blueGray-500 pr-4") {
i(class = "lg:text-blueGray-300 text-blueGray-500 far text-lg leading-lg mr-2 fa-solid fa-magnifying-glass")
}
div(class = "mb-3 pt-0") {
input(
id = "search-local-business",
bind:value = search,
type = "search",
placeholder = "Znajdź",
class = "border-transparent shadow px-3 py-2 text-sm w-full placeholder-blueGray-200 text-blueGray-700 relative bg-white rounded-md outline-none focus:ring focus:ring-lightBlue-500 focus:ring-1 focus:border-lightBlue-500 border border-solid transition duration-200"
)
}
}
}
div(class = "mb-12 flex flex-wrap -mx-4") {
Indexed(
iterable = visible,
view = |cx, business| view! { cx,
LocalBusinessCard(
name = business.name,
desc = business.description,
items = business.items
)
}
)
}
}
}
}
#[component]
pub fn LocalBusinesses<G: Html>(cx: Scope) -> View<G> {
view!(
cx,
Suspense(fallback = view! { cx, "Loading..." }) {
LocalBusinessesList()
}
)
}

View File

@ -0,0 +1,5 @@
mod local_business;
mod local_businesses;
use local_business::*;
pub use local_businesses::*;

View File

@ -0,0 +1,48 @@
use account::sign_in::SignIn;
use local_businesses::LocalBusinesses;
use sycamore::prelude::*;
use sycamore_router::*;
use crate::components::{Footer, Header};
mod account;
mod local_businesses;
#[derive(Route)]
enum Routes {
#[to("/")]
Home,
#[to("/account")]
Account,
#[not_found]
NotFound,
}
#[component]
pub fn AppRoutes<G: Html>(cx: Scope) -> View<G> {
view! {
cx,
Header()
Router(
integration=HistoryIntegration::new(),
view=|cx, route: &ReadSignal<Routes>| {
view! {
cx,
article(class="w-full") {
(match route.get().as_ref() {
Routes::Home => view! { cx,
LocalBusinesses()
},
Routes::Account => view! { cx,
SignIn()
},
Routes::NotFound => view! { cx,
"404 Not Found"
},
})
}
}
}
)
Footer()
}
}