Compare commits
6 Commits
8b5f55db6f
...
8a5605812c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a5605812c | ||
|
|
d27196d7e5 | ||
|
|
9cbcdc533e | ||
|
|
f18899b1a6 | ||
|
|
3bfac6d012 | ||
|
|
0b402c6259 |
@@ -24,7 +24,11 @@
|
||||
--color-amber-200: oklch(92.4% 0.12 95.746);
|
||||
--color-amber-600: oklch(66.6% 0.179 58.318);
|
||||
--color-amber-800: oklch(47.3% 0.137 46.201);
|
||||
--color-yellow-50: oklch(98.7% 0.026 102.212);
|
||||
--color-yellow-200: oklch(94.5% 0.129 101.54);
|
||||
--color-yellow-500: oklch(79.5% 0.184 86.047);
|
||||
--color-yellow-600: oklch(68.1% 0.162 75.834);
|
||||
--color-yellow-800: oklch(47.6% 0.114 61.907);
|
||||
--color-green-50: oklch(98.2% 0.018 155.826);
|
||||
--color-green-100: oklch(96.2% 0.044 156.743);
|
||||
--color-green-200: oklch(92.5% 0.084 155.995);
|
||||
@@ -44,6 +48,7 @@
|
||||
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
||||
--color-indigo-700: oklch(45.7% 0.24 277.023);
|
||||
--color-purple-100: oklch(94.6% 0.033 307.174);
|
||||
--color-purple-200: oklch(90.2% 0.063 306.703);
|
||||
--color-purple-500: oklch(62.7% 0.265 303.9);
|
||||
--color-purple-600: oklch(55.8% 0.288 302.321);
|
||||
--color-purple-700: oklch(49.6% 0.265 301.924);
|
||||
@@ -298,6 +303,424 @@
|
||||
.my-12 {
|
||||
margin-block: calc(var(--spacing) * 12);
|
||||
}
|
||||
.prose {
|
||||
color: var(--tw-prose-body);
|
||||
max-width: 65ch;
|
||||
:where(p):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
:where([class~="lead"]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-lead);
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
:where(a):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-links);
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
:where(strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-bold);
|
||||
font-weight: 600;
|
||||
}
|
||||
:where(a strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
}
|
||||
:where(blockquote strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
}
|
||||
:where(thead th strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
}
|
||||
:where(ol):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: decimal;
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-inline-start: 1.625em;
|
||||
}
|
||||
:where(ol[type="A"]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: upper-alpha;
|
||||
}
|
||||
:where(ol[type="a"]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
:where(ol[type="A" s]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: upper-alpha;
|
||||
}
|
||||
:where(ol[type="a" s]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
:where(ol[type="I"]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: upper-roman;
|
||||
}
|
||||
:where(ol[type="i"]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
:where(ol[type="I" s]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: upper-roman;
|
||||
}
|
||||
:where(ol[type="i" s]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
:where(ol[type="1"]):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
:where(ul):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
list-style-type: disc;
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-inline-start: 1.625em;
|
||||
}
|
||||
:where(ol > li):not(:where([class~="not-prose"],[class~="not-prose"] *))::marker {
|
||||
font-weight: 400;
|
||||
color: var(--tw-prose-counters);
|
||||
}
|
||||
:where(ul > li):not(:where([class~="not-prose"],[class~="not-prose"] *))::marker {
|
||||
color: var(--tw-prose-bullets);
|
||||
}
|
||||
:where(dt):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-headings);
|
||||
font-weight: 600;
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
:where(hr):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
border-color: var(--tw-prose-hr);
|
||||
border-top-width: 1px;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
:where(blockquote):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: var(--tw-prose-quotes);
|
||||
border-inline-start-width: 0.25rem;
|
||||
border-inline-start-color: var(--tw-prose-quote-borders);
|
||||
quotes: "\201C""\201D""\2018""\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-inline-start: 1em;
|
||||
}
|
||||
:where(blockquote p:first-of-type):not(:where([class~="not-prose"],[class~="not-prose"] *))::before {
|
||||
content: open-quote;
|
||||
}
|
||||
:where(blockquote p:last-of-type):not(:where([class~="not-prose"],[class~="not-prose"] *))::after {
|
||||
content: close-quote;
|
||||
}
|
||||
:where(h1):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-headings);
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
:where(h1 strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
font-weight: 900;
|
||||
color: inherit;
|
||||
}
|
||||
:where(h2):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-headings);
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
:where(h2 strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
font-weight: 800;
|
||||
color: inherit;
|
||||
}
|
||||
:where(h3):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-headings);
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
:where(h3 strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
}
|
||||
:where(h4):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-headings);
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
:where(h4 strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
}
|
||||
:where(img):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
:where(picture):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
display: block;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
:where(video):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
:where(kbd):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
color: var(--tw-prose-kbd);
|
||||
box-shadow: 0 0 0 1px var(--tw-prose-kbd-shadows), 0 3px 0 var(--tw-prose-kbd-shadows);
|
||||
font-size: 0.875em;
|
||||
border-radius: 0.3125rem;
|
||||
padding-top: 0.1875em;
|
||||
padding-inline-end: 0.375em;
|
||||
padding-bottom: 0.1875em;
|
||||
padding-inline-start: 0.375em;
|
||||
}
|
||||
:where(code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-code);
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
:where(code):not(:where([class~="not-prose"],[class~="not-prose"] *))::before {
|
||||
content: "`";
|
||||
}
|
||||
:where(code):not(:where([class~="not-prose"],[class~="not-prose"] *))::after {
|
||||
content: "`";
|
||||
}
|
||||
:where(a code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
}
|
||||
:where(h1 code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
}
|
||||
:where(h2 code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
:where(h3 code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
:where(h4 code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
}
|
||||
:where(blockquote code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
}
|
||||
:where(thead th code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: inherit;
|
||||
}
|
||||
:where(pre):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-pre-code);
|
||||
background-color: var(--tw-prose-pre-bg);
|
||||
overflow-x: auto;
|
||||
font-weight: 400;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-inline-end: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-inline-start: 1.1428571em;
|
||||
}
|
||||
:where(pre code):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
:where(pre code):not(:where([class~="not-prose"],[class~="not-prose"] *))::before {
|
||||
content: none;
|
||||
}
|
||||
:where(pre code):not(:where([class~="not-prose"],[class~="not-prose"] *))::after {
|
||||
content: none;
|
||||
}
|
||||
:where(table):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
:where(thead):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--tw-prose-th-borders);
|
||||
}
|
||||
:where(thead th):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-headings);
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-inline-end: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-inline-start: 0.5714286em;
|
||||
}
|
||||
:where(tbody tr):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--tw-prose-td-borders);
|
||||
}
|
||||
:where(tbody tr:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
:where(tbody td):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
:where(tfoot):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
border-top-width: 1px;
|
||||
border-top-color: var(--tw-prose-th-borders);
|
||||
}
|
||||
:where(tfoot td):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
vertical-align: top;
|
||||
}
|
||||
:where(th, td):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
text-align: start;
|
||||
}
|
||||
:where(figure > *):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
:where(figcaption):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
color: var(--tw-prose-captions);
|
||||
font-size: 0.875em;
|
||||
line-height: 1.4285714;
|
||||
margin-top: 0.8571429em;
|
||||
}
|
||||
--tw-prose-body: oklch(37.3% 0.034 259.733);
|
||||
--tw-prose-headings: oklch(21% 0.034 264.665);
|
||||
--tw-prose-lead: oklch(44.6% 0.03 256.802);
|
||||
--tw-prose-links: oklch(21% 0.034 264.665);
|
||||
--tw-prose-bold: oklch(21% 0.034 264.665);
|
||||
--tw-prose-counters: oklch(55.1% 0.027 264.364);
|
||||
--tw-prose-bullets: oklch(87.2% 0.01 258.338);
|
||||
--tw-prose-hr: oklch(92.8% 0.006 264.531);
|
||||
--tw-prose-quotes: oklch(21% 0.034 264.665);
|
||||
--tw-prose-quote-borders: oklch(92.8% 0.006 264.531);
|
||||
--tw-prose-captions: oklch(55.1% 0.027 264.364);
|
||||
--tw-prose-kbd: oklch(21% 0.034 264.665);
|
||||
--tw-prose-kbd-shadows: color-mix(in oklab, oklch(21% 0.034 264.665) 10%, transparent);
|
||||
--tw-prose-code: oklch(21% 0.034 264.665);
|
||||
--tw-prose-pre-code: oklch(92.8% 0.006 264.531);
|
||||
--tw-prose-pre-bg: oklch(27.8% 0.033 256.848);
|
||||
--tw-prose-th-borders: oklch(87.2% 0.01 258.338);
|
||||
--tw-prose-td-borders: oklch(92.8% 0.006 264.531);
|
||||
--tw-prose-invert-body: oklch(87.2% 0.01 258.338);
|
||||
--tw-prose-invert-headings: #fff;
|
||||
--tw-prose-invert-lead: oklch(70.7% 0.022 261.325);
|
||||
--tw-prose-invert-links: #fff;
|
||||
--tw-prose-invert-bold: #fff;
|
||||
--tw-prose-invert-counters: oklch(70.7% 0.022 261.325);
|
||||
--tw-prose-invert-bullets: oklch(44.6% 0.03 256.802);
|
||||
--tw-prose-invert-hr: oklch(37.3% 0.034 259.733);
|
||||
--tw-prose-invert-quotes: oklch(96.7% 0.003 264.542);
|
||||
--tw-prose-invert-quote-borders: oklch(37.3% 0.034 259.733);
|
||||
--tw-prose-invert-captions: oklch(70.7% 0.022 261.325);
|
||||
--tw-prose-invert-kbd: #fff;
|
||||
--tw-prose-invert-kbd-shadows: rgb(255, 255, 255 / 10%);
|
||||
--tw-prose-invert-code: #fff;
|
||||
--tw-prose-invert-pre-code: oklch(87.2% 0.01 258.338);
|
||||
--tw-prose-invert-pre-bg: rgb(0 0 0 / 50%);
|
||||
--tw-prose-invert-th-borders: oklch(44.6% 0.03 256.802);
|
||||
--tw-prose-invert-td-borders: oklch(37.3% 0.034 259.733);
|
||||
font-size: 1rem;
|
||||
line-height: 1.75;
|
||||
:where(picture > img):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
:where(li):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
:where(ol > li):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
padding-inline-start: 0.375em;
|
||||
}
|
||||
:where(ul > li):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
padding-inline-start: 0.375em;
|
||||
}
|
||||
:where(.prose > ul > li p):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
:where(.prose > ul > li > p:first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
:where(.prose > ul > li > p:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
:where(.prose > ol > li > p:first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
:where(.prose > ol > li > p:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
:where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
:where(dl):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
:where(dd):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0.5em;
|
||||
padding-inline-start: 1.625em;
|
||||
}
|
||||
:where(hr + *):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
}
|
||||
:where(h2 + *):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
}
|
||||
:where(h3 + *):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
}
|
||||
:where(h4 + *):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
}
|
||||
:where(thead th:first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
:where(thead th:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
:where(tbody td, tfoot td):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
padding-top: 0.5714286em;
|
||||
padding-inline-end: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-inline-start: 0.5714286em;
|
||||
}
|
||||
:where(tbody td:first-child, tfoot td:first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
:where(tbody td:last-child, tfoot td:last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
:where(figure):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
:where(.prose > :first-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
}
|
||||
:where(.prose > :last-child):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
.mt-0 {
|
||||
margin-top: calc(var(--spacing) * 0);
|
||||
}
|
||||
.mt-0\.5 {
|
||||
margin-top: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
@@ -382,6 +805,9 @@
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.h-0 {
|
||||
height: calc(var(--spacing) * 0);
|
||||
}
|
||||
.h-0\.5 {
|
||||
height: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
@@ -469,12 +895,18 @@
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.scale-x-0 {
|
||||
--tw-scale-x: 0%;
|
||||
scale: var(--tw-scale-x) var(--tw-scale-y);
|
||||
@@ -633,6 +1065,10 @@
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.border-b-2 {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
.border-l-4 {
|
||||
border-left-style: var(--tw-border-style);
|
||||
border-left-width: 4px;
|
||||
@@ -659,6 +1095,9 @@
|
||||
.border-green-200 {
|
||||
border-color: var(--color-green-200);
|
||||
}
|
||||
.border-purple-700 {
|
||||
border-color: var(--color-purple-700);
|
||||
}
|
||||
.border-red-200 {
|
||||
border-color: var(--color-red-200);
|
||||
}
|
||||
@@ -671,6 +1110,9 @@
|
||||
.border-white {
|
||||
border-color: var(--color-white);
|
||||
}
|
||||
.border-yellow-200 {
|
||||
border-color: var(--color-yellow-200);
|
||||
}
|
||||
.border-l-green-500 {
|
||||
border-left-color: var(--color-green-500);
|
||||
}
|
||||
@@ -731,6 +1173,9 @@
|
||||
background-color: color-mix(in oklab, var(--color-white) 95%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-yellow-50 {
|
||||
background-color: var(--color-yellow-50);
|
||||
}
|
||||
.bg-gradient-to-br {
|
||||
--tw-gradient-position: to bottom right in oklab;
|
||||
background-image: linear-gradient(var(--tw-gradient-stops));
|
||||
@@ -747,6 +1192,14 @@
|
||||
--tw-gradient-from: var(--color-blue-600);
|
||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
||||
}
|
||||
.from-purple-600 {
|
||||
--tw-gradient-from: var(--color-purple-600);
|
||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
||||
}
|
||||
.to-blue-600 {
|
||||
--tw-gradient-to: var(--color-blue-600);
|
||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
||||
}
|
||||
.to-blue-700 {
|
||||
--tw-gradient-to: var(--color-blue-700);
|
||||
--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position));
|
||||
@@ -792,6 +1245,9 @@
|
||||
.px-8 {
|
||||
padding-inline: calc(var(--spacing) * 8);
|
||||
}
|
||||
.py-0 {
|
||||
padding-block: calc(var(--spacing) * 0);
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-block: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
@@ -963,6 +1419,12 @@
|
||||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
.text-yellow-600 {
|
||||
color: var(--color-yellow-600);
|
||||
}
|
||||
.text-yellow-800 {
|
||||
color: var(--color-yellow-800);
|
||||
}
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
@@ -970,6 +1432,9 @@
|
||||
--tw-ordinal: ordinal;
|
||||
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.placeholder-gray-400 {
|
||||
&::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
@@ -990,11 +1455,19 @@
|
||||
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.backdrop-blur-md {
|
||||
--tw-backdrop-blur: blur(var(--blur-md));
|
||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||
}
|
||||
.backdrop-filter {
|
||||
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
|
||||
}
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
@@ -1193,6 +1666,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:text-purple-200 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-purple-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:text-red-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -1381,12 +1861,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.md\:text-4xl {
|
||||
@media (width >= 48rem) {
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||
}
|
||||
}
|
||||
.lg\:ml-0 {
|
||||
@media (width >= 64rem) {
|
||||
margin-left: calc(var(--spacing) * 0);
|
||||
@@ -2198,6 +2672,11 @@
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-backdrop-blur {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -2288,6 +2767,7 @@
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-outline-style: solid;
|
||||
--tw-backdrop-blur: initial;
|
||||
--tw-backdrop-brightness: initial;
|
||||
--tw-backdrop-contrast: initial;
|
||||
|
||||
@@ -28,6 +28,7 @@ pub use subscriptions::*;
|
||||
pub use subscriptions_confirm::*;
|
||||
pub use unsubscribe::*;
|
||||
pub use users::*;
|
||||
use validator::ValidationErrors;
|
||||
|
||||
use crate::{
|
||||
authentication::AuthError,
|
||||
@@ -209,3 +210,20 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join_error_messages(e: ValidationErrors) -> String {
|
||||
let error_messages: Vec<_> = e
|
||||
.field_errors()
|
||||
.iter()
|
||||
.flat_map(|(field, errors)| {
|
||||
errors.iter().map(move |error| {
|
||||
error
|
||||
.message
|
||||
.as_ref()
|
||||
.map(|msg| msg.to_string())
|
||||
.unwrap_or(format!("Invalid field: {}", field))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
error_messages.join("\n")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::authentication::AuthenticatedUser;
|
||||
use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page};
|
||||
use crate::routes::{COMMENTS_PER_PAGE, Query, get_max_page, join_error_messages};
|
||||
use crate::session_state::TypedSession;
|
||||
use crate::templates::{ErrorTemplate, MessageTemplate, PostsPageDashboardTemplate};
|
||||
use crate::{
|
||||
@@ -19,6 +19,7 @@ use axum::{
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
pub const POSTS_PER_PAGE: i64 = 3;
|
||||
|
||||
@@ -119,9 +120,11 @@ pub async fn get_posts_count(connection_pool: &PgPool) -> Result<i64, sqlx::Erro
|
||||
.map(|r| r.count.unwrap())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(Validate, serde::Deserialize)]
|
||||
pub struct EditPostForm {
|
||||
#[validate(length(min = 1, message = "Title must be at least one character."))]
|
||||
pub title: String,
|
||||
#[validate(length(min = 1, message = "Content must be at least one character."))]
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
@@ -141,6 +144,10 @@ pub async fn update_post(
|
||||
match record {
|
||||
None => Ok(HtmlTemplate(ErrorTemplate::NotFound).into_response()),
|
||||
Some(record) if record.author_id == user_id => {
|
||||
if let Err(e) = form.validate().map_err(join_error_messages) {
|
||||
let template = HtmlTemplate(MessageTemplate::error(e));
|
||||
return Ok(template.into_response());
|
||||
}
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE posts
|
||||
@@ -158,7 +165,10 @@ pub async fn update_post(
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
_ => Ok(HtmlTemplate(ErrorTemplate::Forbidden).into_response()),
|
||||
_ => Ok(HtmlTemplate(MessageTemplate::error(
|
||||
"You are not authorized. Only the author can edit his post.".into(),
|
||||
))
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::authentication::AuthenticatedUser;
|
||||
use crate::routes::verify_password;
|
||||
use crate::routes::{join_error_messages, verify_password};
|
||||
use crate::session_state::TypedSession;
|
||||
use crate::templates::{ErrorTemplate, MessageTemplate, UserEditTemplate};
|
||||
use crate::templates::{MessageTemplate, UserEditTemplate};
|
||||
use crate::{
|
||||
authentication::Role,
|
||||
domain::{PostEntry, UserEntry},
|
||||
@@ -18,6 +18,7 @@ use axum::{
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
pub async fn user_edit_form(
|
||||
Extension(AuthenticatedUser { user_id, .. }): Extension<AuthenticatedUser>,
|
||||
@@ -41,9 +42,10 @@ pub async fn user_edit_form(
|
||||
Ok(template.into_response())
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(Debug, Validate, serde::Deserialize)]
|
||||
pub struct EditProfileForm {
|
||||
user_id: Uuid,
|
||||
#[validate(length(min = 3, message = "Username must be at least 3 characters."))]
|
||||
username: String,
|
||||
full_name: String,
|
||||
bio: String,
|
||||
@@ -62,8 +64,14 @@ pub async fn update_user(
|
||||
}): Extension<AuthenticatedUser>,
|
||||
Form(form): Form<EditProfileForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
if let Err(e) = form.validate().map_err(join_error_messages) {
|
||||
let template = HtmlTemplate(MessageTemplate::error(e));
|
||||
return Ok(template.into_response());
|
||||
}
|
||||
if form.user_id != session_user_id {
|
||||
let template = HtmlTemplate(ErrorTemplate::Forbidden);
|
||||
let template = HtmlTemplate(MessageTemplate::error(
|
||||
"You are not authorized. Refresh the page and try again.".into(),
|
||||
));
|
||||
return Ok(template.into_response());
|
||||
}
|
||||
let updated_username = form.username.trim();
|
||||
@@ -78,7 +86,7 @@ pub async fn update_user(
|
||||
.is_some()
|
||||
{
|
||||
let template = HtmlTemplate(MessageTemplate::error(
|
||||
"The username is already taken.".into(),
|
||||
"This username is already taken.".into(),
|
||||
));
|
||||
return Ok(template.into_response());
|
||||
}
|
||||
|
||||
@@ -1,109 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="description" content="zero2prod newsletter"/>
|
||||
<meta name="keywords" content="newsletter, rust, axum, htmx"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>
|
||||
{% block title %}{% endblock %}
|
||||
- zero2prod
|
||||
</title>
|
||||
<link href="/assets/css/main.css" rel="stylesheet"/>
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="flex items-center space-x-2 group">
|
||||
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-200">
|
||||
<svg class="w-4 h-4 text-white group-hover:scale-110 transition-transform duration-200"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
zero2prod
|
||||
</span>
|
||||
</a>
|
||||
<nav class="hidden md:flex items-center">
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 relative group">
|
||||
Posts
|
||||
<span class="absolute inset-x-4 bottom-0 h-0.5 bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-transform duration-200"></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/dashboard"
|
||||
hx-boost="true"
|
||||
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
|
||||
Dashboard
|
||||
</a>
|
||||
<button class="md:hidden p-2 text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200"
|
||||
onclick="toggleMobileMenu()">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"/>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="zero2prod newsletter" />
|
||||
<meta name="keywords" content="newsletter, rust, axum, htmx" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
{% block title %}{% endblock %}
|
||||
- zero2prod
|
||||
</title>
|
||||
<link href="/assets/css/main.css" rel="stylesheet" />
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col">
|
||||
<div class="bg-yellow-50 border-b-2 border-yellow-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div class="flex items-center justify-center text-center">
|
||||
<svg class="w-5 h-5 text-yellow-600 mr-2 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-menu"
|
||||
class="hidden md:hidden border-t border-gray-100 pb-4 pt-4">
|
||||
<nav class="flex flex-col space-y-2">
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
Posts
|
||||
</a>
|
||||
<a href="/dashboard"
|
||||
hx-boost="true"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
Dashboard
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1">
|
||||
<main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
|
||||
Gitea
|
||||
<svg class="ml-1 h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</a>
|
||||
<span class="text-gray-300">•</span>
|
||||
<a href="/unsubscribe"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors">Unsubscribe</a>
|
||||
<p class="text-sm text-yellow-800">
|
||||
This is a personal project, <span class="font-semibold">not</span> a real website. Database is cleared periodically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="text-xs text-gray-500">Built with ❤️ using Rust, axum & htmx</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
<header class="sticky top-0 bg-white/95 backdrop-blur-md shadow-sm border-b border-gray-200 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="flex items-center space-x-2 group">
|
||||
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-200">
|
||||
<svg class="w-4 h-4 text-white group-hover:scale-110 transition-transform duration-200"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold bg-gradient-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
|
||||
zero2prod
|
||||
</span>
|
||||
</a>
|
||||
<nav class="hidden md:flex items-center">
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 relative group">
|
||||
Posts
|
||||
<span class="absolute inset-x-4 bottom-0 h-0.5 bg-blue-600 scale-x-0 group-hover:scale-x-100 transition-transform duration-200"></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/dashboard"
|
||||
hx-boost="true"
|
||||
class="hidden md:flex bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-md hover:shadow-lg transform hover:scale-105">
|
||||
Dashboard
|
||||
</a>
|
||||
<button class="md:hidden p-2 text-gray-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors duration-200"
|
||||
onclick="toggleMobileMenu()">
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mobile-menu"
|
||||
class="hidden md:hidden border-t border-gray-100 pb-4 pt-4">
|
||||
<nav class="flex flex-col space-y-2">
|
||||
<a href="/posts"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
Posts
|
||||
</a>
|
||||
<a href="/dashboard"
|
||||
hx-boost="true"
|
||||
class="text-gray-700 hover:text-blue-600 hover:bg-blue-50 px-4 py-2 rounded-lg text-sm font-medium transition-colors duration-200">
|
||||
Dashboard
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="flex flex-1">
|
||||
<main class="flex-1 lg:ml-0 flex flex-col py-8 px-4 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||
<div class="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="https://gitea.alphonsepaix.xyz/alphonse/zero2prod"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors flex items-center">
|
||||
Gitea
|
||||
<svg class="ml-1 h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<span class="text-gray-300">•</span>
|
||||
<a href="/unsubscribe"
|
||||
class="text-sm text-gray-500 hover:text-gray-900 transition-colors">Unsubscribe</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 md:mt-0">
|
||||
<p class="text-xs text-gray-500">Built with ❤️ using Rust, axum & htmx</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
<script>
|
||||
function toggleMobileMenu() {
|
||||
|
||||
@@ -1,111 +1,107 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit: {{ post.title }}{% endblock %}
|
||||
{% block title %}{{ post.title }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<article>
|
||||
<header class="mb-4">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
|
||||
<svg class="w-4 h-4 text-blue-600"
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<article>
|
||||
<header class="mb-4">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4 leading-tight">{{ post.title }}</h1>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between text-sm text-gray-600">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center mr-2">
|
||||
<svg class="w-4 h-4 text-blue-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<a href="/users/{{ post.author }}"
|
||||
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
</time>
|
||||
</div>
|
||||
<a href="/users/{{ post.author }}"
|
||||
class="hover:text-blue-600 hover:underline font-medium">{{ post.author }}</a>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<time datetime="{{ post.published_at }}">
|
||||
{{ post.formatted_date() }}
|
||||
</time>
|
||||
</div>
|
||||
{% if session_username.as_deref() == Some(post.author) %}
|
||||
<div class="mt-4 sm:mt-0">
|
||||
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
|
||||
<svg class="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if session_username.as_deref() == Some(post.author) %}
|
||||
<div class="mt-4 sm:mt-0">
|
||||
<button onclick="document.getElementById('edit-form').classList.toggle('hidden')"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
|
||||
<svg class="w-4 h-4 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
</header>
|
||||
{% if session_username.as_deref() == Some(post.author) %}
|
||||
<div id="edit-form"
|
||||
class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
|
||||
<form hx-put="/posts/{{ post.post_id }}"
|
||||
hx-target="#edit-messages"
|
||||
hx-swap="innerHTML">
|
||||
<div class="mb-4">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||
<input type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value="{{ post.title }}"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">Content (markdown)</label>
|
||||
<textarea id="content"
|
||||
name="content"
|
||||
rows="12"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">{{ post.content }}</textarea>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition-colors">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Save changes
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="document.getElementById('edit-form').classList.add('hidden')"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-md transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="edit-messages" class="mt-6"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if session_username.as_deref() == Some(post.author) %}
|
||||
<div id="edit-form" class="hidden bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||
<h2 class="text-xl font-bold text-gray-900 mb-4">Edit post</h2>
|
||||
<form hx-put="/posts/{{ post.post_id }}"
|
||||
hx-target="#edit-messages"
|
||||
hx-swap="innerHTML">
|
||||
<div class="mb-4">
|
||||
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">Title</label>
|
||||
<input type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value="{{ post.title }}"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="content" class="block text-sm font-medium text-gray-700 mb-2">Content (markdown)</label>
|
||||
<textarea id="content"
|
||||
name="content"
|
||||
rows="12"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm">{{ post.content }}</textarea>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-md transition-colors">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
Save changes
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="document.getElementById('edit-form').classList.add('hidden')"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium rounded-md transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="edit-messages" class="mt-6"></div>
|
||||
{% endif %}
|
||||
<div id="content-display" class="prose-compact">{{ post_html | safe }}</div>
|
||||
</article>
|
||||
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
|
||||
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
||||
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
||||
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
||||
<a href="/#newsletter-signup"
|
||||
class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
|
||||
Subscribe
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="content-display" class="prose-compact">{{ post_html | safe }}</div>
|
||||
</article>
|
||||
<div class="mt-8">{% include "posts/comments/list.html" %}</div>
|
||||
<div class="mt-8 bg-gradient-to-r from-blue-600 to-indigo-700 rounded-lg shadow-lg text-white p-8 text-center">
|
||||
<h3 class="text-2xl font-bold mb-2">Enjoyed this post?</h3>
|
||||
<p class="text-blue-100 mb-4">Subscribe to my newsletter for more insights on Rust backend development.</p>
|
||||
<a href="/#newsletter-signup"
|
||||
class="inline-block bg-white text-blue-600 hover:bg-gray-100 font-semibold py-3 px-6 rounded-md transition-colors">
|
||||
Subscribe
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit profile{% endblock %}
|
||||
{% block content %}
|
||||
<div class="max-w-5xl mx-auto p-4 sm:p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Edit Profile</h1>
|
||||
<p class="mt-2 text-gray-600">Manage your profile and account settings.</p>
|
||||
<div class="max-w-5xl mx-auto p-4 sm:p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Edit your profile</h1>
|
||||
<p class="mt-2 text-gray-600">Manage your profile and account settings.</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{% include "edit/update_profile.html" %}
|
||||
{% include "edit/change_password.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{% include "edit/update_profile.html" %}
|
||||
{% include "edit/change_password.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Change Password</h2>
|
||||
|
||||
<form hx-post="/password"
|
||||
hx-target="#password-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<div>
|
||||
<label for="current_password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
||||
<input type="password"
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
|
||||
<input type="password"
|
||||
id="new_password"
|
||||
name="new_password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_password_check"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
|
||||
<input type="password"
|
||||
id="new_password_check"
|
||||
name="new_password_check"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500"/>
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Update password
|
||||
</button>
|
||||
<div id="password-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Password</h2>
|
||||
<form hx-post="/password"
|
||||
hx-target="#password-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-4">
|
||||
<div>
|
||||
<label for="current_password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Current password</label>
|
||||
<input type="password"
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">New password</label>
|
||||
<input type="password"
|
||||
id="new_password"
|
||||
name="new_password"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="new_password_check"
|
||||
class="block text-sm font-medium text-gray-700 mb-2">Confirm new password</label>
|
||||
<input type="password"
|
||||
id="new_password_check"
|
||||
name="new_password_check"
|
||||
required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full bg-green-600 text-white hover:bg-green-700 font-medium py-3 px-4 rounded-md transition-colors flex items-center justify-center">
|
||||
<svg class="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Update
|
||||
</button>
|
||||
<div id="password-messages" class="mt-4"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
<div class="bg-white rounded-lg shadow-md border border-gray-200 p-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Profile Information</h2>
|
||||
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-6">Profile settings</h2>
|
||||
<form hx-put="/users/edit"
|
||||
hx-target="#edit-messages"
|
||||
hx-swap="innerHTML"
|
||||
class="space-y-6">
|
||||
<input type="hidden" name="user_id" value="{{ user.user_id }}" />
|
||||
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||
<input type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
value="{{ user.username }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Full Name
|
||||
</label>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
|
||||
<input type="text"
|
||||
id="full_name"
|
||||
name="full_name"
|
||||
value="{{ user.full_name.as_deref().unwrap_or("") }}"
|
||||
placeholder="John Doe"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
placeholder="John Doe"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="mt-1 text-xs text-gray-500">Your real name (optional)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Bio
|
||||
</label>
|
||||
<label for="bio" class="block text-sm font-medium text-gray-700 mb-1">Bio</label>
|
||||
<textarea id="bio"
|
||||
name="bio"
|
||||
rows="4"
|
||||
@@ -43,12 +33,10 @@
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">{{ user.bio.as_deref().unwrap_or("") }}</textarea>
|
||||
<p class="mt-1 text-xs text-gray-500">Maximum 500 characters</p>
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="w-full bg-blue-600 text-white hover:bg-blue-700 font-medium py-2 px-4 rounded-md transition-colors">
|
||||
Save changes
|
||||
</button>
|
||||
|
||||
<div id="edit-messages"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ async fn visitor_can_leave_a_comment(connection_pool: PgPool) {
|
||||
"idempotency_key": "key",
|
||||
});
|
||||
app.post_comment(&post_id, &comment_body).await;
|
||||
let post = app.get_post_html(post_id).await;
|
||||
let post = app.get_post_html(&post_id).await;
|
||||
assert!(post.contains(comment_author));
|
||||
assert!(post.contains(comment_content));
|
||||
}
|
||||
@@ -44,7 +44,7 @@ async fn visitor_can_comment_anonymously(connection_pool: PgPool) {
|
||||
"idempotency_key": "key",
|
||||
});
|
||||
app.post_comment(&post_id, &comment_body).await;
|
||||
let post = app.get_post_html(post_id).await;
|
||||
let post = app.get_post_html(&post_id).await;
|
||||
assert!(post.contains("Anonymous"));
|
||||
assert!(post.contains(comment_content));
|
||||
}
|
||||
|
||||
@@ -289,6 +289,18 @@ impl TestApp {
|
||||
self.get_admin_dashboard().await.text().await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn edit_post<Body>(&self, body: &Body, post_id: &Uuid) -> reqwest::Response
|
||||
where
|
||||
Body: serde::Serialize,
|
||||
{
|
||||
self.api_client
|
||||
.put(format!("{}/posts/{}", self.address, post_id))
|
||||
.form(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn get_posts(&self) -> reqwest::Response {
|
||||
self.api_client
|
||||
.get(format!("{}/posts", &self.address))
|
||||
@@ -301,7 +313,7 @@ impl TestApp {
|
||||
self.get_posts().await.text().await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn get_post(&self, post_id: Uuid) -> reqwest::Response {
|
||||
pub async fn get_post(&self, post_id: &Uuid) -> reqwest::Response {
|
||||
self.api_client
|
||||
.get(format!("{}/posts/{}", &self.address, post_id))
|
||||
.send()
|
||||
@@ -309,7 +321,7 @@ impl TestApp {
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn get_post_html(&self, post_id: Uuid) -> String {
|
||||
pub async fn get_post_html(&self, post_id: &Uuid) -> String {
|
||||
self.get_post(post_id).await.text().await.unwrap()
|
||||
}
|
||||
|
||||
@@ -375,6 +387,30 @@ impl TestApp {
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn edit_profile<Body>(&self, body: &Body) -> reqwest::Response
|
||||
where
|
||||
Body: serde::Serialize,
|
||||
{
|
||||
self.api_client
|
||||
.put(format!("{}/users/edit", self.address))
|
||||
.form(body)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn get_profile(&self, username: &str) -> reqwest::Response {
|
||||
self.api_client
|
||||
.get(format!("{}/users/{}", self.address, username))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn get_profile_html(&self, username: &str) -> String {
|
||||
self.get_profile(username).await.text().await.unwrap()
|
||||
}
|
||||
|
||||
pub async fn post_create_post<Body>(&self, body: &Body) -> reqwest::Response
|
||||
where
|
||||
Body: serde::Serialize,
|
||||
@@ -426,6 +462,14 @@ impl TestApp {
|
||||
.await
|
||||
.expect("Failed to execute request")
|
||||
}
|
||||
|
||||
pub async fn get_user_id(&self, username: &str) -> Uuid {
|
||||
let record = sqlx::query!("SELECT user_id FROM users WHERE username = $1", username)
|
||||
.fetch_one(&self.connection_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
record.user_id
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_is_redirect_to(response: &reqwest::Response, location: &str) {
|
||||
|
||||
@@ -144,7 +144,7 @@ async fn new_posts_are_visible_on_the_website(connection_pool: PgPool) {
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = app.get_post_html(post.post_id).await;
|
||||
let html = app.get_post_html(&post.post_id).await;
|
||||
assert!(html.contains(&title));
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ async fn visitor_can_read_a_blog_post(connection_pool: PgPool) {
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let html = app.get_post_html(post.post_id).await;
|
||||
let html = app.get_post_html(&post.post_id).await;
|
||||
assert!(html.contains(&title));
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ async fn a_deleted_blog_post_returns_404(connection_pool: PgPool) {
|
||||
|
||||
app.delete_post(post.post_id).await;
|
||||
|
||||
let html = app.get_post_html(post.post_id).await;
|
||||
let html = app.get_post_html(&post.post_id).await;
|
||||
assert!(html.contains("Not Found"));
|
||||
}
|
||||
|
||||
@@ -234,3 +234,109 @@ async fn clicking_the_notification_link_marks_the_email_as_opened(connection_poo
|
||||
.opened
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn only_post_author_can_access_the_edit_form(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
let username = "alphonse";
|
||||
let password = "123456789abc";
|
||||
app.create_user(username, password, false).await;
|
||||
let login_body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
app.post_login(&login_body).await;
|
||||
app.post_create_post(&fake_post_body()).await;
|
||||
let post_id = sqlx::query!("SELECT post_id FROM posts")
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.post_id;
|
||||
let html = app.get_post_html(&post_id).await;
|
||||
assert!(html.contains("Edit"));
|
||||
|
||||
app.logout().await;
|
||||
app.admin_login().await;
|
||||
let html = app.get_post_html(&post_id).await;
|
||||
assert!(!html.contains("Edit"));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn only_post_author_can_edit_post(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
let username = "alphonse";
|
||||
let password = "123456789abc";
|
||||
app.create_user(username, password, false).await;
|
||||
let login_body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
app.post_login(&login_body).await;
|
||||
app.post_create_post(&fake_post_body()).await;
|
||||
let post_id = sqlx::query!("SELECT post_id FROM posts")
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.post_id;
|
||||
|
||||
let new_title = "Stunning new title";
|
||||
let new_content = "Astonishing content";
|
||||
let edit_body = serde_json::json!({
|
||||
"title": new_title,
|
||||
"content": new_content,
|
||||
});
|
||||
let response = app.edit_post(&edit_body, &post_id).await;
|
||||
let text = response.text().await.unwrap();
|
||||
assert!(text.contains("Your changes have been saved"));
|
||||
let text = app.get_post_html(&post_id).await;
|
||||
assert!(text.contains(new_title));
|
||||
assert!(text.contains(new_content));
|
||||
|
||||
app.logout().await;
|
||||
app.admin_login().await;
|
||||
let response = app.edit_post(&edit_body, &post_id).await;
|
||||
let text = response.text().await.unwrap();
|
||||
assert!(text.contains("You are not authorized."));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn invalid_fields_are_rejected(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
app.post_create_post(&fake_post_body()).await;
|
||||
let post_id = sqlx::query!("SELECT post_id FROM posts")
|
||||
.fetch_one(&app.connection_pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.post_id;
|
||||
|
||||
let test_cases = [
|
||||
(
|
||||
serde_json::json!({
|
||||
"title": "",
|
||||
"content": "content"
|
||||
}),
|
||||
"Title must be at least one character",
|
||||
"title was empty",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"title": "Title",
|
||||
"content": ""
|
||||
}),
|
||||
"Content must be at least one character",
|
||||
"content was empty",
|
||||
),
|
||||
];
|
||||
for (invalid_body, expected_error_message, explaination) in test_cases {
|
||||
let response = app.edit_post(&invalid_body, &post_id).await;
|
||||
let text = response.text().await.unwrap();
|
||||
assert!(
|
||||
text.contains(expected_error_message),
|
||||
"The API did not reject the changes when the {}",
|
||||
explaination
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,3 +344,184 @@ async fn writers_cannot_perform_admin_functions(connection_pool: PgPool) {
|
||||
.unwrap();
|
||||
assert!(record.is_none());
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn user_can_change_his_display_name(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
let username = "alphonse";
|
||||
let password = "123456789abc";
|
||||
app.create_user(username, password, false).await;
|
||||
let user_id = app.get_user_id(username).await;
|
||||
let login_body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
app.post_login(&login_body).await;
|
||||
let full_name = "Alphonse Paix";
|
||||
let edit_body = serde_json::json!( {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"full_name": full_name,
|
||||
"bio": "",
|
||||
});
|
||||
let html = app.get_profile_html(username).await;
|
||||
assert!(!html.contains(full_name));
|
||||
let response = app.edit_profile(&edit_body).await;
|
||||
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
|
||||
let html = app.get_profile_html(username).await;
|
||||
assert!(html.contains(full_name));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn user_can_change_his_bio(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
let username = "alphonse";
|
||||
let password = "123456789abc";
|
||||
app.create_user(username, password, false).await;
|
||||
let user_id = app.get_user_id(username).await;
|
||||
let login_body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
app.post_login(&login_body).await;
|
||||
let bio = "This is me";
|
||||
let edit_body = serde_json::json!( {
|
||||
"user_id": user_id,
|
||||
"username": username,
|
||||
"full_name": "",
|
||||
"bio": bio,
|
||||
});
|
||||
let html = app.get_profile_html(username).await;
|
||||
assert!(!html.contains(bio));
|
||||
let response = app.edit_profile(&edit_body).await;
|
||||
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
|
||||
let html = app.get_profile_html(username).await;
|
||||
assert!(html.contains(bio));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn user_can_change_his_username(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
let username = "alphonse";
|
||||
let password = "123456789abc";
|
||||
app.create_user(username, password, false).await;
|
||||
let user_id = app.get_user_id(username).await;
|
||||
let login_body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
app.post_login(&login_body).await;
|
||||
let new_username = "alphonsepaix";
|
||||
let edit_body = serde_json::json!( {
|
||||
"user_id": user_id,
|
||||
"username": new_username,
|
||||
"full_name": "",
|
||||
"bio": "",
|
||||
});
|
||||
let html = app.get_profile_html(username).await;
|
||||
assert!(html.contains(username));
|
||||
let response = app.edit_profile(&edit_body).await;
|
||||
assert!(dbg!(response.text().await.unwrap()).contains("Your profile has been updated"));
|
||||
let html = app.get_profile_html(username).await;
|
||||
assert!(html.contains("404"));
|
||||
let html = app.get_profile_html(new_username).await;
|
||||
assert!(html.contains(new_username));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn user_cannot_change_other_profiles(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
let username = "alphonse";
|
||||
let password = "123456789abc";
|
||||
app.create_user(username, password, false).await;
|
||||
let other_user_id = app.get_user_id("admin").await;
|
||||
let login_body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
app.post_login(&login_body).await;
|
||||
let new_username = "alphonsepaix";
|
||||
let edit_body = serde_json::json!( {
|
||||
"user_id": other_user_id,
|
||||
"username": new_username,
|
||||
"full_name": "",
|
||||
"bio": "",
|
||||
});
|
||||
let response = app.edit_profile(&edit_body).await;
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("You are not authorized")
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn user_cannot_take_an_existing_username(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
let username = "alphonse";
|
||||
let password = "123456789abc";
|
||||
app.create_user(username, password, false).await;
|
||||
let user_id = app.get_user_id(username).await;
|
||||
let login_body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
app.post_login(&login_body).await;
|
||||
let edit_body = serde_json::json!( {
|
||||
"user_id": user_id,
|
||||
"username": "admin",
|
||||
"full_name": "",
|
||||
"bio": "",
|
||||
});
|
||||
let response = app.edit_profile(&edit_body).await;
|
||||
assert!(
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.unwrap()
|
||||
.contains("This username is already taken")
|
||||
);
|
||||
let html = app.get_profile_html(username).await;
|
||||
assert!(html.contains(username));
|
||||
}
|
||||
|
||||
#[sqlx::test]
|
||||
async fn invalid_fields_are_rejected(connection_pool: PgPool) {
|
||||
let app = TestApp::spawn(connection_pool).await;
|
||||
app.admin_login().await;
|
||||
let username = "alphonse";
|
||||
let password = "123456789abc";
|
||||
app.create_user(username, password, false).await;
|
||||
let user_id = app.get_user_id(username).await;
|
||||
let login_body = serde_json::json!({
|
||||
"username": username,
|
||||
"password": password
|
||||
});
|
||||
app.post_login(&login_body).await;
|
||||
|
||||
let test_cases = [(
|
||||
serde_json::json!({
|
||||
"user_id": user_id,
|
||||
"username": "ab",
|
||||
"full_name": "",
|
||||
"bio": "",
|
||||
}),
|
||||
"Username must be at least 3 characters",
|
||||
"the username was too short",
|
||||
)];
|
||||
for (invalid_body, expected_error_message, explaination) in test_cases {
|
||||
let html = app.edit_profile(&invalid_body).await;
|
||||
assert!(
|
||||
html.text().await.unwrap().contains(expected_error_message),
|
||||
"The API did not reject the changes when {}",
|
||||
explaination
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user