Switch to tailwind for css styling
Could use more partials to reduce some of the current repetition (especially forms), but this is a start with everything converted.
This commit is contained in:
parent
89f37279e5
commit
4eee21caed
Binary file not shown.
@ -1,288 +1,15 @@
|
|||||||
/* Global */
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
html {
|
.list-loading {
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1.6em;
|
|
||||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 8px;
|
|
||||||
height: calc(100vh - 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
main#main-content {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin: 0px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.htmx-indicator {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.htmx-request .list-loading {
|
.htmx-request .list-loading {
|
||||||
display: block;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.htmx-request.list-loading {
|
.htmx-request.list-loading {
|
||||||
display: block;
|
display: inline;
|
||||||
}
|
|
||||||
|
|
||||||
.list-loading {
|
|
||||||
margin: 24px auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
img.loading {
|
|
||||||
filter: invert(100%);
|
|
||||||
max-width: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
|
|
||||||
header.header nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.header nav h1 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.header nav a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.header nav ul {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.header nav ul li {
|
|
||||||
margin-left: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.header nav .auth {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer */
|
|
||||||
|
|
||||||
footer.footer {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 64px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer.footer hr {
|
|
||||||
width: 64px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Home */
|
|
||||||
|
|
||||||
ul.entries {
|
|
||||||
list-style: none;
|
|
||||||
margin: 24px 8px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li.entry {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.entry-link {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
em.entry-link-domain {
|
|
||||||
margin-left: 8px;
|
|
||||||
color: rgba(0, 0, 0, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log */
|
|
||||||
|
|
||||||
pre#log {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Entry */
|
|
||||||
|
|
||||||
article {
|
|
||||||
max-width: 35em;
|
|
||||||
margin: 24px auto;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
article .title {
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
article span.published {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
article img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Feeds */
|
|
||||||
|
|
||||||
span.error {
|
|
||||||
color: crimson;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.feeds {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(auto, 1fr) minmax(200px, 500px);
|
|
||||||
grid-template-areas: 'feeds add-feed';
|
|
||||||
grid-gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
div.feeds {
|
|
||||||
grid-template-columns: minmax(0, 1fr);
|
|
||||||
grid-template-areas: 'feeds' 'add-feed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.feeds-list {
|
|
||||||
grid-area: 'feeds';
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul#feeds li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul#feeds li a {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.add-feed {
|
|
||||||
grid-area: 'add-feed';
|
|
||||||
}
|
|
||||||
|
|
||||||
form.feed-form .form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: fit-content(100%) minmax(100px, 400px);
|
|
||||||
grid-gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.feed-form .form-grid label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
grid-column: 1 / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.feed-form .form-grid input, form.feed-form .form-grid textarea {
|
|
||||||
font-size: 14px;
|
|
||||||
grid-column: 2 / 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.feed-form .form-grid textarea {
|
|
||||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.feed-form .form-grid button {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
grid-column: 3 / 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.stream-messages {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
overflow-x: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul.stream-messages li {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: no-wrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Feed */
|
|
||||||
|
|
||||||
header.feed-header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
header.feed-header button {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
margin-left: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Register & Login */
|
|
||||||
|
|
||||||
.center-horizontal {
|
|
||||||
width: fit-content;
|
|
||||||
margin: 0px auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center-text {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: fit-content(100%) minmax(100px, 400px);
|
|
||||||
grid-gap: 16px;
|
|
||||||
margin: 16px auto;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form-grid label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: bold;
|
|
||||||
grid-column: 1;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form-grid input {
|
|
||||||
font-size: 16px;
|
|
||||||
grid-column: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form-grid button {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
grid-column: 2;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form-grid .forgot-password {
|
|
||||||
grid-column: 2;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-form-grid span.error {
|
|
||||||
font-size: 16px;
|
|
||||||
grid-column: 2 / 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.readable-width {
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import htmx from 'htmx.org';
|
import htmx from 'htmx.org';
|
||||||
|
|
||||||
// import assets so they get named with a content hash that busts caches
|
// import assets so they get named with a content hash that busts caches
|
||||||
import '../css/styles.css';
|
// import '../css/styles.css';
|
||||||
|
|
||||||
import './localTimeController';
|
import './localTimeController';
|
||||||
|
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
"name": "crawlnicle-frontend",
|
"name": "crawlnicle-frontend",
|
||||||
"module": "js/index.ts",
|
"module": "js/index.ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||||
"@typescript-eslint/parser": "^6.5.0",
|
"@typescript-eslint/parser": "^6.5.0",
|
||||||
"bun-types": "^1.0.3",
|
"bun-types": "^1.0.3",
|
||||||
@ -13,6 +15,7 @@
|
|||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"eslint-plugin-promise": "^6.1.1",
|
"eslint-plugin-promise": "^6.1.1",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.0.3",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.2.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
20
frontend/tailwind.config.js
Normal file
20
frontend/tailwind.config.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const plugin = require('tailwindcss/plugin');
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./src/**/*.rs'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
plugin(({ addBase }) =>
|
||||||
|
addBase({
|
||||||
|
html: {
|
||||||
|
fontSize: '16px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
require('@tailwindcss/typography'),
|
||||||
|
require('@tailwindcss/forms'),
|
||||||
|
],
|
||||||
|
};
|
9
justfile
9
justfile
@ -8,6 +8,7 @@ clean-frontend:
|
|||||||
rm -rf ./static/js/* ./static/css/* ./static/img/*
|
rm -rf ./static/js/* ./static/css/* ./static/img/*
|
||||||
|
|
||||||
build-frontend: clean-frontend
|
build-frontend: clean-frontend
|
||||||
|
bunx tailwindcss -i frontend/css/styles.css -o static/css/styles.css --minify
|
||||||
bun build frontend/js/index.ts \
|
bun build frontend/js/index.ts \
|
||||||
--outdir ./static \
|
--outdir ./static \
|
||||||
--root ./frontend \
|
--root ./frontend \
|
||||||
@ -22,6 +23,7 @@ build-frontend: clean-frontend
|
|||||||
touch .frontend-built # trigger build.rs to run
|
touch .frontend-built # trigger build.rs to run
|
||||||
|
|
||||||
build-dev-frontend: clean-frontend
|
build-dev-frontend: clean-frontend
|
||||||
|
bunx tailwindcss -i frontend/css/styles.css -o static/css/styles.css
|
||||||
bun build frontend/js/index.ts \
|
bun build frontend/js/index.ts \
|
||||||
--outdir ./static \
|
--outdir ./static \
|
||||||
--root ./frontend \
|
--root ./frontend \
|
||||||
@ -38,14 +40,19 @@ watch-frontend: install-frontend
|
|||||||
cargo watch -w frontend \
|
cargo watch -w frontend \
|
||||||
-s 'just build-dev-frontend'
|
-s 'just build-dev-frontend'
|
||||||
|
|
||||||
|
build-dev-backend: build-dev-frontend
|
||||||
|
cargo run
|
||||||
|
|
||||||
watch-backend:
|
watch-backend:
|
||||||
mold -run cargo watch \
|
mold -run cargo watch \
|
||||||
--ignore 'logs/*' \
|
--ignore 'logs/*' \
|
||||||
--ignore 'static/*' \
|
--ignore 'static/*' \
|
||||||
--ignore 'frontend/*' \
|
--ignore 'frontend/*' \
|
||||||
|
--ignore 'content' \
|
||||||
--ignore 'content/*' \
|
--ignore 'content/*' \
|
||||||
--no-vcs-ignores \
|
--no-vcs-ignores \
|
||||||
-x run
|
--why \
|
||||||
|
-s 'just build-dev-backend'
|
||||||
|
|
||||||
# runs watch-frontend and watch-backend simultaneously
|
# runs watch-frontend and watch-backend simultaneously
|
||||||
watch:
|
watch:
|
||||||
|
@ -22,6 +22,6 @@ pub async fn get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ApiResponse::Html(
|
Ok(ApiResponse::Html(
|
||||||
entry_list(entries, &options).into_string(),
|
entry_list(entries, &options, false).into_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
@ -21,5 +21,7 @@ pub async fn get(
|
|||||||
return Ok::<ApiResponse<Vec<Feed>>, Error>(ApiResponse::Json(feeds));
|
return Ok::<ApiResponse<Vec<Feed>>, Error>(ApiResponse::Json(feeds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(ApiResponse::Html(feed_list(feeds, &options).into_string()))
|
Ok(ApiResponse::Html(
|
||||||
|
feed_list(feeds, &options, false).into_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ use crate::models::user::User;
|
|||||||
use crate::models::user_email_verification_token::UserEmailVerificationToken;
|
use crate::models::user_email_verification_token::UserEmailVerificationToken;
|
||||||
use crate::partials::confirm_email_form::{confirm_email_form, ConfirmEmailFormProps};
|
use crate::partials::confirm_email_form::{confirm_email_form, ConfirmEmailFormProps};
|
||||||
use crate::partials::layout::Layout;
|
use crate::partials::layout::Layout;
|
||||||
|
use crate::partials::link::{link, LinkProps};
|
||||||
use crate::uuid::Base62Uuid;
|
use crate::uuid::Base62Uuid;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@ -48,16 +49,18 @@ pub fn confirm_email_page(
|
|||||||
.with_subtitle("confirm email")
|
.with_subtitle("confirm email")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { (header.unwrap_or("Confirm your email address")) }
|
h2 class="mb-4 text-2xl font-medium" {
|
||||||
|
(header.unwrap_or("Confirm your email address"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if let Some(desc) = desc {
|
@if let Some(desc) = desc {
|
||||||
(desc)
|
(desc)
|
||||||
} @else {
|
} @else {
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"Enter your email to resend the confirmation email. If you don't have an account yet, create one "
|
"Enter your email to resend the confirmation email. If you don't have an account yet, create one "
|
||||||
a href="/register" { "here" }
|
(link(LinkProps { destination: "/register", title: "here", ..Default::default() }))
|
||||||
"."
|
"."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -102,7 +105,7 @@ pub async fn get(
|
|||||||
},
|
},
|
||||||
header: Some("Email verification token is expired"),
|
header: Some("Email verification token is expired"),
|
||||||
desc: Some(html! {
|
desc: Some(html! {
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"Click the button below to resend a new confirmation email. The link in the email will be valid for another 24 hours."
|
"Click the button below to resend a new confirmation email. The link in the email will be valid for another 24 hours."
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@ -115,13 +118,13 @@ pub async fn get(
|
|||||||
.with_subtitle("confirm email")
|
.with_subtitle("confirm email")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { "Your email is now confirmed!" }
|
h2 class="mb-4 text-2xl font-medium" { "Your email is now confirmed!" }
|
||||||
}
|
}
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"Thanks for verifying your email address. "
|
"Thanks for verifying your email address. "
|
||||||
a href="/" { "Return home" }
|
(link(LinkProps { destination: "/", title: "Return home", ..Default::default() }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
@ -170,11 +173,11 @@ pub async fn post(
|
|||||||
.with_subtitle("confirm email")
|
.with_subtitle("confirm email")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { "Resent confirmation email" }
|
h2 class="mb-4 text-2xl font-medium" { "Resent confirmation email" }
|
||||||
}
|
}
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"Please follow the link sent in the email."
|
"Please follow the link sent in the email."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -193,11 +196,11 @@ pub async fn post(
|
|||||||
.with_subtitle("confirm email")
|
.with_subtitle("confirm email")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { "Resent confirmation email" }
|
h2 class="mb-4 text-2xl font-medium" { "Resent confirmation email" }
|
||||||
}
|
}
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"If the email you entered matched an existing account, then a confirmation email was sent. Please follow the link sent in the email."
|
"If the email you entered matched an existing account, then a confirmation email was sent. Please follow the link sent in the email."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,9 +212,9 @@ pub async fn post(
|
|||||||
form_props: ConfirmEmailFormProps::default(),
|
form_props: ConfirmEmailFormProps::default(),
|
||||||
header: Some("Email verification token not found"),
|
header: Some("Email verification token not found"),
|
||||||
desc: Some(html! {
|
desc: Some(html! {
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"Enter your email to resend the confirmation email. If you don't have an account yet, create one "
|
"Enter your email to resend the confirmation email. If you don't have an account yet, create one "
|
||||||
a href="/register" { "here" }
|
(link(LinkProps { destination: "/register", title: "here", ..Default::default() }))
|
||||||
"."
|
"."
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
@ -11,5 +11,5 @@ pub async fn get(
|
|||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Markup> {
|
) -> Result<Markup> {
|
||||||
let entries = Entry::get_all(&pool, &options).await?;
|
let entries = Entry::get_all(&pool, &options).await?;
|
||||||
Ok(entry_list(entries, &options))
|
Ok(entry_list(entries, &options, false))
|
||||||
}
|
}
|
||||||
|
@ -32,12 +32,14 @@ pub async fn get(
|
|||||||
.with_subtitle(&title)
|
.with_subtitle(&title)
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
article {
|
article class="prose lg:prose-xl my-6 mx-auto prose-a:text-blue-600 prose-a:no-underline visited:prose-a:text-purple-600 hover:prose-a:underline" {
|
||||||
header {
|
header {
|
||||||
h2 class="title" { a href=(entry.url) { (title) } }
|
h2 class="mb-4 text-2xl font-medium" {
|
||||||
|
a href=(entry.url) { (title) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div {
|
div {
|
||||||
span class="published" {
|
span class="text-sm text-gray-600" {
|
||||||
strong { "Published: " }
|
strong { "Published: " }
|
||||||
time datetime=(published_at) class="local-time" {
|
time datetime=(published_at) class="local-time" {
|
||||||
(published_at)
|
(published_at)
|
||||||
|
@ -41,17 +41,20 @@ pub async fn get(
|
|||||||
let entries = Entry::get_all(&pool, &options).await?;
|
let entries = Entry::get_all(&pool, &options).await?;
|
||||||
let delete_url = format!("/feed/{}/delete", id);
|
let delete_url = format!("/feed/{}/delete", id);
|
||||||
Ok(layout.with_subtitle(&title).targeted(hx_target).render(html! {
|
Ok(layout.with_subtitle(&title).targeted(hx_target).render(html! {
|
||||||
header class="feed-header" {
|
header class="mb-4 flex flex-row items-center gap-4" {
|
||||||
h2 { (title) }
|
h2 class="text-2xl font-medium" { (title) }
|
||||||
button class="edit-feed" { "✏️ Edit feed" }
|
button class="py-2 px-4 font-medium rounded-md border border-gray-200" { "✏️ Edit feed" }
|
||||||
form action=(delete_url) method="post" {
|
form action=(delete_url) method="post" {
|
||||||
button type="submit" class="remove-feed" data-controller="remove-feed" { "❌ Remove feed" }
|
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200" { "❌ Remove feed" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if let Some(description) = feed.description {
|
@if let Some(description) = feed.description {
|
||||||
p { (description) }
|
p class="mb-4" { (description) }
|
||||||
|
}
|
||||||
|
hr class="my-4";
|
||||||
|
ul id="entry-list" class="list-none flex flex-col gap-4" {
|
||||||
|
(entry_list(entries, &options, true))
|
||||||
}
|
}
|
||||||
(entry_list(entries, &options))
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,8 +104,8 @@ impl IntoResponse for AddFeedError {
|
|||||||
self.status_code(),
|
self.status_code(),
|
||||||
html! {
|
html! {
|
||||||
(add_feed_form())
|
(add_feed_form())
|
||||||
ul class="stream-messages" {
|
ul class="overflow-x-hidden whitespace-nowrap text-ellipsis" {
|
||||||
li { span class="error" { (self) } }
|
li { span class="text-red-600" { (self) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
@ -145,17 +148,24 @@ pub async fn post(
|
|||||||
crawls.insert(feed.feed_id, receiver);
|
crawls.insert(feed.feed_id, receiver);
|
||||||
}
|
}
|
||||||
|
|
||||||
let feed_stream = format!("connect:/feed/{}/stream", Base62Uuid::from(feed.feed_id));
|
let feed_stream = format!(
|
||||||
|
"connect:/feed/{}/stream swap:message",
|
||||||
|
Base62Uuid::from(feed.feed_id)
|
||||||
|
);
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
html! {
|
html! {
|
||||||
(add_feed_form())
|
(add_feed_form())
|
||||||
div hx-sse=(feed_stream) {
|
ul
|
||||||
ul class="stream-messages" hx-sse="swap:message" hx-swap="beforeend" {
|
id="add-feed-messages"
|
||||||
|
class="overflow-x-hidden whitespace-nowrap text-ellipsis"
|
||||||
|
hx-sse=(feed_stream)
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-target="#add-feed-messages"
|
||||||
|
{
|
||||||
li { "Fetching feed..." }
|
li { "Fetching feed..." }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.into_string(),
|
.into_string(),
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
@ -178,7 +188,7 @@ pub async fn stream(
|
|||||||
Ok::<Event, String>(
|
Ok::<Event, String>(
|
||||||
Event::default().data(
|
Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { "Crawled feed: " (feed_link(&feed, false)) }
|
li hx-target="#main-content" hx-swap="innerHTML" { "Crawled feed: " (feed_link(&feed)) }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
),
|
),
|
||||||
@ -188,7 +198,7 @@ pub async fn stream(
|
|||||||
entry,
|
entry,
|
||||||
)))) => Ok(Event::default().data(
|
)))) => Ok(Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { "Crawled entry: " (entry_link(&entry)) }
|
li hx-target="#main-content" hx-swap="innerHTML" { "Crawled entry: " (entry_link(&entry)) }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
)),
|
)),
|
||||||
@ -196,7 +206,7 @@ pub async fn stream(
|
|||||||
error,
|
error,
|
||||||
)))) => Ok(Event::default().data(
|
)))) => Ok(Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li id=(feed_id) { span class="error" { (error) } }
|
li id=(feed_id) { span class="text-red-600" { (error) } }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
)),
|
)),
|
||||||
@ -204,13 +214,13 @@ pub async fn stream(
|
|||||||
error,
|
error,
|
||||||
)))) => Ok(Event::default().data(
|
)))) => Ok(Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { span class="error" { (error) } }
|
li { span class="text-red-600" { (error) } }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
)),
|
)),
|
||||||
Ok(CrawlSchedulerHandleMessage::Schedule(Err(error))) => Ok(Event::default().data(
|
Ok(CrawlSchedulerHandleMessage::Schedule(Err(error))) => Ok(Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { span class="error" { (error) } }
|
li { span class="text-red-600" { (error) } }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
)),
|
)),
|
||||||
|
@ -23,13 +23,13 @@ pub async fn get(
|
|||||||
.with_subtitle("feeds")
|
.with_subtitle("feeds")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
header { h2 { "Feeds" } }
|
header { h2 class="mb-4 text-2xl font-medium" { "Feeds" } }
|
||||||
div class="feeds" {
|
div class="flex flex-col gap-6 lg:flex-row md:justify-between" {
|
||||||
ul id="feeds" {
|
ul id="feed-list" class="list-none flex flex-col gap-4" {
|
||||||
(feed_list(feeds, &options))
|
(feed_list(feeds, &options, true))
|
||||||
}
|
}
|
||||||
div class="add-feed" {
|
div class="flex flex-col gap-6 max-w-md" {
|
||||||
h3 { "Add Feed" }
|
h3 class="text-xl font-medium" { "Add Feed" }
|
||||||
(add_feed_form())
|
(add_feed_form())
|
||||||
(opml_import_form())
|
(opml_import_form())
|
||||||
}
|
}
|
||||||
|
@ -33,11 +33,11 @@ pub fn forgot_password_page(
|
|||||||
.with_subtitle("forgot password")
|
.with_subtitle("forgot password")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { "Forgot Password" }
|
h2 class="mb-4 text-2xl font-medium" { "Forgot Password" }
|
||||||
}
|
}
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
|
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
|
||||||
}
|
}
|
||||||
(forgot_password_form(form_props))
|
(forgot_password_form(form_props))
|
||||||
@ -54,11 +54,11 @@ pub fn confirm_forgot_password_sent_page(
|
|||||||
.with_subtitle("forgot password")
|
.with_subtitle("forgot password")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { "Reset password email sent" }
|
h2 class="mb-4 text-2xl font-medium" { "Reset password email sent" }
|
||||||
}
|
}
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
|
"If the email you entered matched an existing account with a verified email, then a password reset email was sent. Please follow the link sent in the email."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,8 +17,8 @@ pub async fn get(
|
|||||||
let options = Default::default();
|
let options = Default::default();
|
||||||
let entries = Entry::get_all(&pool, &options).await?;
|
let entries = Entry::get_all(&pool, &options).await?;
|
||||||
Ok(layout.targeted(hx_target).render(html! {
|
Ok(layout.targeted(hx_target).render(html! {
|
||||||
ul class="entries" {
|
ul class="list-none flex flex-col gap-4" {
|
||||||
(entry_list(entries, &options))
|
(entry_list(entries, &options, true))
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
@ -33,17 +33,21 @@ pub async fn opml(
|
|||||||
imports.insert(import_id.as_uuid(), receiver);
|
imports.insert(import_id.as_uuid(), receiver);
|
||||||
}
|
}
|
||||||
|
|
||||||
let import_stream = format!("connnect:/import/{}/stream", import_id);
|
let import_stream = format!("connnect:/import/{}/stream swap:message", import_id);
|
||||||
return Ok((
|
return Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
html! {
|
html! {
|
||||||
(opml_import_form())
|
(opml_import_form())
|
||||||
div hx-sse=(import_stream) {
|
ul
|
||||||
ul class="stream-messages" hx-sse="swap:message" hx-swap="beforeend" {
|
id="import-feeds-messages"
|
||||||
|
class="overflow-x-hidden whitespace-nowrap text-ellipsis"
|
||||||
|
hx-sse=(import_stream)
|
||||||
|
hx-swap="beforeend"
|
||||||
|
hx-target="#import-feeds-messages"
|
||||||
|
{
|
||||||
li { "Uploading..."}
|
li { "Uploading..."}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.into_string(),
|
.into_string(),
|
||||||
)
|
)
|
||||||
.into_response());
|
.into_response());
|
||||||
@ -76,7 +80,7 @@ pub async fn stream(
|
|||||||
))) => Ok::<Event, String>(
|
))) => Ok::<Event, String>(
|
||||||
Event::default().data(
|
Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { "Crawled entry: " (entry_link(&entry)) }
|
li hx-target="#main-content" hx-swap="innerHTML" { "Crawled entry: " (entry_link(&entry)) }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
),
|
),
|
||||||
@ -86,7 +90,7 @@ pub async fn stream(
|
|||||||
))) => Ok::<Event, String>(
|
))) => Ok::<Event, String>(
|
||||||
Event::default().data(
|
Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { "Crawled feed: " (feed_link(&feed, false)) }
|
li hx-target="#main-content" hx-swap="innerHTML" { "Crawled feed: " (feed_link(&feed)) }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
),
|
),
|
||||||
@ -96,7 +100,7 @@ pub async fn stream(
|
|||||||
))) => Ok::<Event, String>(
|
))) => Ok::<Event, String>(
|
||||||
Event::default().data(
|
Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { span class="error" { (error) } }
|
li { span class="text-red-600" { (error) } }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
),
|
),
|
||||||
@ -106,7 +110,7 @@ pub async fn stream(
|
|||||||
))) => Ok::<Event, String>(
|
))) => Ok::<Event, String>(
|
||||||
Event::default().data(
|
Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { span class="error" { (error) } }
|
li { span class="text-red-600" { (error) } }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
),
|
),
|
||||||
@ -116,7 +120,7 @@ pub async fn stream(
|
|||||||
)))) => Ok::<Event, String>(
|
)))) => Ok::<Event, String>(
|
||||||
Event::default().data(
|
Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { span class="error" { (error) } }
|
li { span class="text-red-600" { (error) } }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
),
|
),
|
||||||
@ -125,7 +129,7 @@ pub async fn stream(
|
|||||||
Event::default().data(
|
Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li {
|
li {
|
||||||
span class="error" {
|
span class="text-red-600" {
|
||||||
"Could not create feed for url: " a href=(url) { (url) }
|
"Could not create feed for url: " a href=(url) { (url) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -135,7 +139,7 @@ pub async fn stream(
|
|||||||
),
|
),
|
||||||
Ok(ImporterHandleMessage::Import(Err(error))) => Ok(Event::default().data(
|
Ok(ImporterHandleMessage::Import(Err(error))) => Ok(Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { span class="error" { (error) } }
|
li { span class="text-red-600" { (error) } }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
)),
|
)),
|
||||||
|
@ -28,7 +28,7 @@ pub async fn get(hx_target: Option<TypedHeader<HXTarget>>, layout: Layout) -> Re
|
|||||||
.with_subtitle("log")
|
.with_subtitle("log")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" hx-target="#log" {
|
pre id="log" class="text-sm" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" hx-target="#log" {
|
||||||
(PreEscaped(convert(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap()))
|
(PreEscaped(convert(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap()))
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
@ -43,9 +43,9 @@ pub fn login_page(
|
|||||||
.with_subtitle("login")
|
.with_subtitle("login")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { "Login" }
|
h2 class="mb-4 text-2xl font-medium" { "Login" }
|
||||||
}
|
}
|
||||||
(login_form(form_props))
|
(login_form(form_props))
|
||||||
}
|
}
|
||||||
|
@ -41,9 +41,9 @@ pub fn register_page(
|
|||||||
.with_subtitle("register")
|
.with_subtitle("register")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { "Register" }
|
h2 class="mb-4 text-2xl font-medium" { "Register" }
|
||||||
}
|
}
|
||||||
(register_form(form_props))
|
(register_form(form_props))
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ use crate::htmx::HXTarget;
|
|||||||
use crate::mailers::reset_password::send_password_reset_email;
|
use crate::mailers::reset_password::send_password_reset_email;
|
||||||
use crate::models::user::UpdateUserPassword;
|
use crate::models::user::UpdateUserPassword;
|
||||||
use crate::models::user_password_reset_token::UserPasswordResetToken;
|
use crate::models::user_password_reset_token::UserPasswordResetToken;
|
||||||
|
use crate::partials::link::{link, LinkProps};
|
||||||
use crate::partials::reset_password_form::{reset_password_form, ResetPasswordFormProps};
|
use crate::partials::reset_password_form::{reset_password_form, ResetPasswordFormProps};
|
||||||
use crate::uuid::Base62Uuid;
|
use crate::uuid::Base62Uuid;
|
||||||
use crate::{models::user::User, partials::layout::Layout};
|
use crate::{models::user::User, partials::layout::Layout};
|
||||||
@ -56,17 +57,21 @@ pub fn invalid_token_page(
|
|||||||
.with_subtitle("reset password")
|
.with_subtitle("reset password")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { (header.unwrap_or("Reset Password")) }
|
h2 class="mb-4 text-2xl font-medium" {
|
||||||
|
(header.unwrap_or("Reset Password"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if let Some(desc) = desc {
|
@if let Some(desc) = desc {
|
||||||
p class="readable-width" { (desc) }
|
p class="my-4 max-w-prose" { (desc) }
|
||||||
}
|
|
||||||
p class="readable-width" {
|
|
||||||
a href="/forgot-password" {
|
|
||||||
"Follow this link to request a new password reset email"
|
|
||||||
}
|
}
|
||||||
|
p class="my-4 max-w-prose" {
|
||||||
|
(link(LinkProps {
|
||||||
|
destination: "/forgot-password",
|
||||||
|
title: "Follow this link to request a new password reset email",
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
"."
|
"."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -95,20 +100,24 @@ pub fn reset_password_page(
|
|||||||
.with_subtitle("reset password")
|
.with_subtitle("reset password")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { (header.unwrap_or("Reset Password")) }
|
h2 class="mb-4 text-2xl font-medium" {
|
||||||
|
(header.unwrap_or("Reset Password"))
|
||||||
}
|
}
|
||||||
p class="readable-width" {
|
}
|
||||||
|
p class="my-4 max-w-prose" {
|
||||||
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
|
"A password reset email will be sent if the email submitted matches an account in the system and the email is verfied. If your email is not verified, " a href="/confirm-email" { "please verify your email first" } "."
|
||||||
}
|
}
|
||||||
(reset_password_form(form_props))
|
(reset_password_form(form_props))
|
||||||
@if let Some(post_form_error) = post_form_error {
|
@if let Some(post_form_error) = post_form_error {
|
||||||
p class="error readable-width" { (post_form_error) }
|
p class="my-4 max-w-prose text-red-600" { (post_form_error) }
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
a href="/forgot-password" {
|
(link(LinkProps {
|
||||||
"Follow this link to request a new password reset email"
|
destination: "/forgot-password",
|
||||||
}
|
title: "Follow this link to request a new password reset email",
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
". The link in the email will be valid for 24 hours."
|
". The link in the email will be valid for 24 hours."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -305,13 +314,13 @@ pub async fn post(
|
|||||||
.with_subtitle("reset password")
|
.with_subtitle("reset password")
|
||||||
.targeted(hx_target)
|
.targeted(hx_target)
|
||||||
.render(html! {
|
.render(html! {
|
||||||
div class="center-horizontal" {
|
div class="w-fit mx-auto" {
|
||||||
header class="center-text" {
|
header class="text-center" {
|
||||||
h2 { "Password reset!" }
|
h2 class="mb-4 text-2xl font-medium" { "Password reset!" }
|
||||||
}
|
}
|
||||||
p class="readable-width" {
|
p class="my-4 max-w-prose" {
|
||||||
"Your password has been reset. "
|
"Your password has been reset. "
|
||||||
a href="/" { "Return home" }
|
(link(LinkProps { destination: "/", title: "Return home", ..Default::default() }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
@ -9,17 +9,22 @@ pub fn add_feed_form() -> Markup {
|
|||||||
hx-post="/feed"
|
hx-post="/feed"
|
||||||
hx-target="#add-feed-form"
|
hx-target="#add-feed-form"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="feed-form"
|
class="flex flex-row gap-6 items-end justify-between"
|
||||||
{
|
{
|
||||||
div class="form-grid" {
|
// TODO: make into an input partial component
|
||||||
label for="url" { "URL: " }
|
div class="grow w-full" {
|
||||||
|
label for="url" class="text-sm font-medium text-gray-700" { "URL" }
|
||||||
input
|
input
|
||||||
type="text"
|
type="text"
|
||||||
id="url"
|
id="url"
|
||||||
name="url"
|
name="url"
|
||||||
placeholder="https://example.com/feed.xml"
|
placeholder="https://example.com/feed.xml"
|
||||||
required="true";
|
required="true"
|
||||||
button type="submit" { "Add Feed" }
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
|
}
|
||||||
|
div class="whitespace-nowrap" {
|
||||||
|
// TODO: make into a button partial component
|
||||||
|
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200" { "Add Feed" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,23 +16,26 @@ pub fn confirm_email_form(props: ConfirmEmailFormProps) -> Markup {
|
|||||||
method="post"
|
method="post"
|
||||||
hx-post="/confirm-email"
|
hx-post="/confirm-email"
|
||||||
id="confirm-email-form"
|
id="confirm-email-form"
|
||||||
class="auth-form-grid"
|
class="my-4 flex flex-col gap-4"
|
||||||
{
|
{
|
||||||
input
|
input
|
||||||
type="text"
|
type="text"
|
||||||
name="token"
|
name="token"
|
||||||
id="token"
|
id="token"
|
||||||
value=(token.map(|t| t.token_id.to_string()).unwrap_or_default())
|
value=(token.map(|t| t.token_id.to_string()).unwrap_or_default())
|
||||||
style="display:none;";
|
class="hidden";
|
||||||
label for="email" { "Email" }
|
div {
|
||||||
|
label for="email" class="text-sm font-medium text-gray-700" { "Email" }
|
||||||
input
|
input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
value=(email.unwrap_or_default())
|
value=(email.unwrap_or_default())
|
||||||
required;
|
required
|
||||||
button type="submit" { "Resend confirmation email" }
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
|
}
|
||||||
|
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200"{ "Resend confirmation email" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,46 @@
|
|||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
use crate::models::entry::Entry;
|
use crate::models::entry::Entry;
|
||||||
|
use crate::partials::link::{link, LinkProps};
|
||||||
use crate::utils::get_domain;
|
use crate::utils::get_domain;
|
||||||
use crate::uuid::Base62Uuid;
|
use crate::uuid::Base62Uuid;
|
||||||
|
|
||||||
pub fn entry_link(entry: &Entry) -> Markup {
|
pub struct EntryLink<'a> {
|
||||||
let title = entry.title.as_ref().map(|s| s.clone()).unwrap_or_else(|| "Untitled".to_string());
|
pub entry: &'a Entry,
|
||||||
let url = format!("/entry/{}", Base62Uuid::from(entry.entry_id));
|
pub reset_htmx_target: bool,
|
||||||
let domain = get_domain(&entry.url).unwrap_or_default();
|
}
|
||||||
|
|
||||||
|
impl EntryLink<'_> {
|
||||||
|
pub fn new(entry: &Entry) -> EntryLink {
|
||||||
|
EntryLink {
|
||||||
|
entry,
|
||||||
|
reset_htmx_target: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_htmx_target(&mut self) -> &mut Self {
|
||||||
|
self.reset_htmx_target = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(&self) -> Markup {
|
||||||
|
let title = self
|
||||||
|
.entry
|
||||||
|
.title
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Untitled".to_string());
|
||||||
|
let url = format!("/entry/{}", Base62Uuid::from(self.entry.entry_id));
|
||||||
|
let domain = get_domain(&self.entry.url).unwrap_or_default();
|
||||||
html! {
|
html! {
|
||||||
a href=(url) class="entry-link" { (title) } em class="entry-link-domain" { (domain) }
|
div class="flex flex-row gap-4" {
|
||||||
|
(link(LinkProps { destination: &url, title: &title, reset_htmx_target: self.reset_htmx_target }))
|
||||||
|
em class="text-gray-600" { (domain) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn entry_link(entry: &Entry) -> Markup {
|
||||||
|
EntryLink::new(entry).render()
|
||||||
|
}
|
||||||
|
@ -1,29 +1,27 @@
|
|||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
use crate::models::entry::{Entry, GetEntriesOptions, DEFAULT_ENTRIES_PAGE_SIZE};
|
use crate::models::entry::{Entry, GetEntriesOptions, DEFAULT_ENTRIES_PAGE_SIZE};
|
||||||
use crate::partials::entry_link::entry_link;
|
use crate::partials::entry_link::{entry_link, EntryLink};
|
||||||
|
|
||||||
pub fn entry_list(entries: Vec<Entry>, options: &GetEntriesOptions) -> Markup {
|
pub fn entry_list(entries: Vec<Entry>, options: &GetEntriesOptions, first_page: bool) -> Markup {
|
||||||
let len = entries.len() as i64;
|
let len = entries.len() as i64;
|
||||||
if len == 0 {
|
if first_page && len == 0 {
|
||||||
return html! { p { "No entries found." } };
|
return html! { p { "No entries found." } };
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut more_query = None;
|
let mut more_query = None;
|
||||||
if len == options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE) {
|
let limit = options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE);
|
||||||
|
if len == limit {
|
||||||
let last_entry = entries.last().unwrap();
|
let last_entry = entries.last().unwrap();
|
||||||
if let Some(feed_id) = options.feed_id {
|
if let Some(feed_id) = options.feed_id {
|
||||||
more_query = Some(format!(
|
more_query = Some(format!(
|
||||||
"/api/v1/entries?feed_id={}&published_before={}&id_before={}",
|
"/api/v1/entries?feed_id={}&published_before={}&id_before={}&limit={}",
|
||||||
feed_id,
|
feed_id, last_entry.published_at, last_entry.entry_id, limit
|
||||||
last_entry.published_at,
|
|
||||||
last_entry.entry_id
|
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
more_query = Some(format!(
|
more_query = Some(format!(
|
||||||
"/api/v1/entries?published_before={}&id_before={}",
|
"/api/v1/entries?published_before={}&id_before={}&limit={}",
|
||||||
last_entry.published_at,
|
last_entry.published_at, last_entry.entry_id, limit
|
||||||
last_entry.entry_id
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -32,17 +30,17 @@ pub fn entry_list(entries: Vec<Entry>, options: &GetEntriesOptions) -> Markup {
|
|||||||
@for (i, entry) in entries.iter().enumerate() {
|
@for (i, entry) in entries.iter().enumerate() {
|
||||||
@if i == entries.len() - 1 {
|
@if i == entries.len() - 1 {
|
||||||
@if let Some(ref more_query) = more_query {
|
@if let Some(ref more_query) = more_query {
|
||||||
li class="entry" hx-get=(more_query) hx-trigger="revealed" hx-swap="afterend" {
|
li hx-get=(more_query) hx-trigger="revealed" hx-target="this" hx-swap="afterend" {
|
||||||
(entry_link(entry))
|
(EntryLink::new(entry).reset_htmx_target().render())
|
||||||
div class="htmx-indicator list-loading" {
|
div class="list-loading" {
|
||||||
img class="loading" src="/static/img/three-dots.svg" alt="Loading...";
|
img class="mt-4 max-h-4 invert" src="/static/img/three-dots.svg" alt="Loading...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
li class="entry" { (entry_link(entry)) }
|
li { (entry_link(entry)) }
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
li class="entry" { (entry_link(entry)) }
|
li { (entry_link(entry)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,49 @@
|
|||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
use crate::models::feed::Feed;
|
use crate::models::feed::Feed;
|
||||||
|
use crate::partials::link::{link, LinkProps};
|
||||||
use crate::uuid::Base62Uuid;
|
use crate::uuid::Base62Uuid;
|
||||||
|
|
||||||
pub fn feed_link(feed: &Feed, pending_crawl: bool) -> Markup {
|
pub struct FeedLink<'a> {
|
||||||
let title = feed.title.clone().unwrap_or_else(|| {
|
pub feed: &'a Feed,
|
||||||
if pending_crawl {
|
pub pending_crawl: bool,
|
||||||
|
pub reset_htmx_target: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeedLink<'_> {
|
||||||
|
pub fn new(feed: &Feed) -> FeedLink {
|
||||||
|
FeedLink {
|
||||||
|
feed,
|
||||||
|
pending_crawl: false,
|
||||||
|
reset_htmx_target: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_crawl(&mut self) -> &mut Self {
|
||||||
|
self.pending_crawl = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_htmx_target(&mut self) -> &mut Self {
|
||||||
|
self.reset_htmx_target = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(&self) -> Markup {
|
||||||
|
let title = self.feed.title.clone().unwrap_or_else(|| {
|
||||||
|
if self.pending_crawl {
|
||||||
"Crawling feed...".to_string()
|
"Crawling feed...".to_string()
|
||||||
} else {
|
} else {
|
||||||
"Untitled Feed".to_string()
|
"Untitled Feed".to_string()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let feed_url = format!("/feed/{}", Base62Uuid::from(feed.feed_id));
|
let feed_url = format!("/feed/{}", Base62Uuid::from(self.feed.feed_id));
|
||||||
html! {
|
html! {
|
||||||
a href=(feed_url) { (title) }
|
(link(LinkProps { destination: &feed_url, title: &title, reset_htmx_target: self.reset_htmx_target }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn feed_link(feed: &Feed) -> Markup {
|
||||||
|
FeedLink::new(feed).render()
|
||||||
|
}
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
use crate::models::feed::{Feed, GetFeedsOptions, DEFAULT_FEEDS_PAGE_SIZE};
|
use crate::models::feed::{Feed, GetFeedsOptions, DEFAULT_FEEDS_PAGE_SIZE};
|
||||||
use crate::partials::feed_link::feed_link;
|
use crate::partials::feed_link::{feed_link, FeedLink};
|
||||||
|
|
||||||
pub fn feed_list(feeds: Vec<Feed>, options: &GetFeedsOptions) -> Markup {
|
pub fn feed_list(feeds: Vec<Feed>, options: &GetFeedsOptions, first_page: bool) -> Markup {
|
||||||
let len = feeds.len() as i64;
|
let len = feeds.len() as i64;
|
||||||
if len == 0 {
|
if first_page && len == 0 {
|
||||||
return html! { p { "No feeds found." } };
|
return html! { p { "No feeds found." } };
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut more_query = None;
|
let mut more_query = None;
|
||||||
if len == options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE) {
|
let limit = options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE);
|
||||||
|
if len == limit {
|
||||||
let last_feed = feeds.last().unwrap();
|
let last_feed = feeds.last().unwrap();
|
||||||
more_query = Some(format!(
|
more_query = Some(format!(
|
||||||
"/api/v1/feeds?sort=CreatedAt&before={}&id_before={}",
|
"/api/v1/feeds?sort=CreatedAt&before={}&id_before={}&limit={}",
|
||||||
last_feed.created_at,
|
last_feed.created_at, last_feed.feed_id, limit
|
||||||
last_feed.feed_id
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,17 +23,17 @@ pub fn feed_list(feeds: Vec<Feed>, options: &GetFeedsOptions) -> Markup {
|
|||||||
@for (i, feed) in feeds.iter().enumerate() {
|
@for (i, feed) in feeds.iter().enumerate() {
|
||||||
@if i == feeds.len() - 1 {
|
@if i == feeds.len() - 1 {
|
||||||
@if let Some(ref more_query) = more_query {
|
@if let Some(ref more_query) = more_query {
|
||||||
li class="feed" hx-get=(more_query) hx-trigger="revealed" hx-swap="afterend" {
|
li hx-get=(more_query) hx-trigger="revealed" hx-target="this" hx-swap="afterend" {
|
||||||
(feed_link(feed, false))
|
(FeedLink::new(feed).reset_htmx_target().render())
|
||||||
div class="htmx-indicator list-loading" {
|
div class="list-loading" {
|
||||||
img class="loading" src="/static/img/three-dots.svg" alt="Loading...";
|
img class="mt-4 max-h-4 invert" src="/static/img/three-dots.svg" alt="Loading...";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
li class="feed" { (feed_link(feed, false)) }
|
li { (feed_link(feed)) }
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
li class="feed" { (feed_link(feed, false)) }
|
li { (feed_link(feed)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
|
use crate::partials::link::{link, LinkProps};
|
||||||
|
|
||||||
pub fn footer() -> Markup {
|
pub fn footer() -> Markup {
|
||||||
html! {
|
html! {
|
||||||
footer class="footer" {
|
footer class="text-center mt-16 mb-2" {
|
||||||
hr;
|
hr class="w-12 mx-auto mb-4";
|
||||||
"Made by " a href="https://www.hallada.net" { "Tyler Hallada" }"."
|
"Made by " (link(LinkProps {
|
||||||
|
destination: "https://www.hallada.net",
|
||||||
|
title: "Tyler Hallada",
|
||||||
|
..Default::default()
|
||||||
|
})) "."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,20 +14,24 @@ pub fn forgot_password_form(props: ForgotPasswordFormProps) -> Markup {
|
|||||||
method="post"
|
method="post"
|
||||||
hx-post="/forgot-password"
|
hx-post="/forgot-password"
|
||||||
id="forgot-password-form"
|
id="forgot-password-form"
|
||||||
class="auth-form-grid"
|
class="my-4 flex flex-col gap-4"
|
||||||
{
|
{
|
||||||
label for="email" { "Email" }
|
div {
|
||||||
|
label for="email" class="text-sm font-medium text-gray-700" { "Email" }
|
||||||
input
|
input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
value=(email.unwrap_or_default())
|
value=(email.unwrap_or_default())
|
||||||
required;
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
@if let Some(email_error) = email_error {
|
@if let Some(email_error) = email_error {
|
||||||
span class="error" { (email_error) }
|
span class="text-red-600" { (email_error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200" {
|
||||||
|
"Send password reset email"
|
||||||
}
|
}
|
||||||
button type="submit" { "Send password reset email" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,40 @@
|
|||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
use crate::models::user::User;
|
use crate::models::user::User;
|
||||||
|
use crate::partials::link::{home_link, link, LinkProps};
|
||||||
use crate::partials::user_name::user_name;
|
use crate::partials::user_name::user_name;
|
||||||
|
|
||||||
pub fn header(title: &str, user: Option<User>) -> Markup {
|
pub fn header(title: &str, user: Option<User>) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
header class="header" {
|
header {
|
||||||
nav {
|
nav class="flex flex-row items-baseline justify-between" {
|
||||||
h1 { a href="/" { (title) } }
|
div class="flex flex-row items-baseline gap-4" {
|
||||||
ul {
|
h1 {
|
||||||
li { a href="/feeds" { "feeds" } }
|
(home_link(LinkProps {
|
||||||
li { a href="/log" { "log" } }
|
destination: "/",
|
||||||
|
title,
|
||||||
|
..Default::default()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
ul class="flex flex-row list-none gap-4" {
|
||||||
|
li { (link(LinkProps { destination: "/feeds", title: "feeds", ..Default::default() })) }
|
||||||
|
li { (link(LinkProps { destination: "/log", title: "log", ..Default::default() })) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div class="auth" {
|
div class="auth" {
|
||||||
@if let Some(user) = user {
|
@if let Some(user) = user {
|
||||||
(user_name(user.clone()))
|
(user_name(user.clone()))
|
||||||
@if !user.email_verified {
|
@if !user.email_verified {
|
||||||
span { " (" }
|
span { " (" }
|
||||||
a href="/confirm-email" { "unverified" }
|
(link(LinkProps { destination: "/confirm-email", title: "unverified", ..Default::default() }))
|
||||||
span { ")" }
|
span { ")" }
|
||||||
}
|
}
|
||||||
span { " | " }
|
span { " | " }
|
||||||
a href="/logout" { "logout" }
|
(link(LinkProps { destination: "/logout", title: "logout", ..Default::default() }))
|
||||||
} @else {
|
} @else {
|
||||||
a href="/login" { "login" }
|
(link(LinkProps { destination: "/login", title: "login", ..Default::default() }))
|
||||||
span { " | " }
|
span { " | " }
|
||||||
a href="/register" { "register" }
|
(link(LinkProps { destination: "/register", title: "register", ..Default::default() }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -154,6 +154,7 @@ impl Layout {
|
|||||||
html lang="en" {
|
html lang="en" {
|
||||||
head {
|
head {
|
||||||
meta charset="utf-8";
|
meta charset="utf-8";
|
||||||
|
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||||
title { (self.full_title()) }
|
title { (self.full_title()) }
|
||||||
@for js_file in js_manifest() {
|
@for js_file in js_manifest() {
|
||||||
script type="module" src=(js_file) {}
|
script type="module" src=(js_file) {}
|
||||||
@ -162,9 +163,9 @@ impl Layout {
|
|||||||
link rel="stylesheet" href=(css_file) {}
|
link rel="stylesheet" href=(css_file) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body hx-boost="true" hx-target="#main-content" {
|
body hx-boost="true" hx-target="#main-content" class="mx-2 text-gray-950" {
|
||||||
(header(&self.title, self.user))
|
(header(&self.title, self.user))
|
||||||
main id="main-content" { (template) }
|
main id="main-content" class="my-6 mx-2 md:mx-4" { (template) }
|
||||||
(footer())
|
(footer())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
47
src/partials/link.rs
Normal file
47
src/partials/link.rs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
use maud::{html, Markup};
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct LinkProps<'a> {
|
||||||
|
pub destination: &'a str,
|
||||||
|
pub title: &'a str,
|
||||||
|
pub reset_htmx_target: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link(
|
||||||
|
LinkProps {
|
||||||
|
destination,
|
||||||
|
title,
|
||||||
|
reset_htmx_target,
|
||||||
|
}: LinkProps<'_>,
|
||||||
|
) -> Markup {
|
||||||
|
let hx_target = if reset_htmx_target {
|
||||||
|
Some("#main-content")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let hx_swap = if reset_htmx_target {
|
||||||
|
Some("unset")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
a
|
||||||
|
href=(destination)
|
||||||
|
hx-target=[hx_target]
|
||||||
|
hx-swap=[hx_swap]
|
||||||
|
class="text-blue-600 visited:text-purple-600 hover:underline"
|
||||||
|
{
|
||||||
|
(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn home_link(
|
||||||
|
LinkProps {
|
||||||
|
destination, title, ..
|
||||||
|
}: LinkProps<'_>,
|
||||||
|
) -> Markup {
|
||||||
|
html! {
|
||||||
|
a href=(destination) class="text-2xl text-blue-600 visited:text-purple-600 hover:underline" { (title) }
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
|
use crate::partials::link::{link, LinkProps};
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct LoginFormProps {
|
pub struct LoginFormProps {
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
@ -23,20 +25,24 @@ pub fn login_form(props: LoginFormProps) -> Markup {
|
|||||||
hx-target="#login-form"
|
hx-target="#login-form"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
id="login-form"
|
id="login-form"
|
||||||
class="auth-form-grid"
|
class="my-4 flex flex-col gap-4"
|
||||||
{
|
{
|
||||||
label for="email" { "Email" }
|
div {
|
||||||
|
label for="email" class="text-sm font-medium text-gray-700" { "Email" }
|
||||||
input
|
input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
value=(email.unwrap_or_default())
|
value=(email.unwrap_or_default())
|
||||||
required;
|
required
|
||||||
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
@if let Some(email_error) = email_error {
|
@if let Some(email_error) = email_error {
|
||||||
span class="error" { (email_error) }
|
span class="text-red-600" { (email_error) }
|
||||||
}
|
}
|
||||||
label for="email" { "Password" }
|
}
|
||||||
|
div {
|
||||||
|
label for="pwassword" class="text-sm font-medium text-gray-700" { "Password" }
|
||||||
input
|
input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
@ -44,16 +50,22 @@ pub fn login_form(props: LoginFormProps) -> Markup {
|
|||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
minlength="8"
|
minlength="8"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
required;
|
required
|
||||||
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
@if let Some(password_error) = password_error {
|
@if let Some(password_error) = password_error {
|
||||||
span class="error" { (password_error) }
|
span class="text-red-600" { (password_error) }
|
||||||
}
|
}
|
||||||
button type="submit" { "Submit" }
|
}
|
||||||
|
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200" { "Submit" }
|
||||||
@if let Some(general_error) = general_error {
|
@if let Some(general_error) = general_error {
|
||||||
span class="error" { (general_error) }
|
span class="text-red-600" { (general_error) }
|
||||||
}
|
}
|
||||||
a href="/forgot-password" hx-target="#main-content" hx-swap="unset" class="forgot-password" {
|
div class="ml-auto" {
|
||||||
"Forgot password"
|
(link(LinkProps {
|
||||||
|
destination: "/forgot-password",
|
||||||
|
title: "Forgot password",
|
||||||
|
reset_htmx_target: true,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ pub mod footer;
|
|||||||
pub mod forgot_password_form;
|
pub mod forgot_password_form;
|
||||||
pub mod header;
|
pub mod header;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
|
pub mod link;
|
||||||
pub mod login_form;
|
pub mod login_form;
|
||||||
pub mod opml_import_form;
|
pub mod opml_import_form;
|
||||||
pub mod register_form;
|
pub mod register_form;
|
||||||
|
@ -6,19 +6,23 @@ pub fn opml_import_form() -> Markup {
|
|||||||
id="opml-import-form"
|
id="opml-import-form"
|
||||||
hx-post="/import/opml"
|
hx-post="/import/opml"
|
||||||
hx-encoding="multipart/form-data"
|
hx-encoding="multipart/form-data"
|
||||||
class="feed-form"
|
class="flex flex-row gap-6 items-end justify-between"
|
||||||
{
|
{
|
||||||
div class="form-grid" {
|
div class="grow w-full" {
|
||||||
label for="opml" { "OPML: " }
|
label for="opml" class="text-sm font-medium text-gray-700" { "OPML" }
|
||||||
input
|
input
|
||||||
type="file"
|
type="file"
|
||||||
id="opml"
|
id="opml"
|
||||||
name="opml"
|
name="opml"
|
||||||
required="true"
|
required="true"
|
||||||
accept="text/x-opml,application/xml,text/xml";
|
accept="text/x-opml,application/xml,text/xml"
|
||||||
button type="submit" { "Import Feeds" }
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
progress id="opml-upload-progress" max="100" value="0" hidden="true" {}
|
|
||||||
}
|
}
|
||||||
|
div class="whitespace-nowrap" {
|
||||||
|
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200" { "Import Feeds" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress id="opml-upload-progress" max="100" value="0" hidden="true" {}
|
||||||
script {
|
script {
|
||||||
(PreEscaped(r#"
|
(PreEscaped(r#"
|
||||||
htmx.on('#opml-import-form', 'htmx:xhr:progress', function (evt) {
|
htmx.on('#opml-import-form', 'htmx:xhr:progress', function (evt) {
|
||||||
@ -30,5 +34,4 @@ pub fn opml_import_form() -> Markup {
|
|||||||
"#))
|
"#))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -27,31 +27,38 @@ pub fn register_form(props: RegisterFormProps) -> Markup {
|
|||||||
hx-target="#register-form"
|
hx-target="#register-form"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
id="register-form"
|
id="register-form"
|
||||||
class="auth-form-grid"
|
class="my-4 flex flex-col gap-4"
|
||||||
{
|
{
|
||||||
label for="email" { "Email *" }
|
div {
|
||||||
|
label for="email" class="text-sm font-medium text-gray-700" { "Email *" }
|
||||||
input
|
input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
value=(email.unwrap_or_default())
|
value=(email.unwrap_or_default())
|
||||||
required;
|
required
|
||||||
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
@if let Some(email_error) = email_error {
|
@if let Some(email_error) = email_error {
|
||||||
span class="error" { (email_error) }
|
span class="text-red-600" { (email_error) }
|
||||||
}
|
}
|
||||||
label for="name" { (PreEscaped("Name ")) }
|
}
|
||||||
|
div {
|
||||||
|
label for="name" class="text-sm font-medium text-gray-700" { (PreEscaped("Name ")) }
|
||||||
input
|
input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
id="name"
|
id="name"
|
||||||
value=(name.unwrap_or_default())
|
value=(name.unwrap_or_default())
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
maxlength="255";
|
maxlength="255"
|
||||||
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
@if let Some(name_error) = name_error {
|
@if let Some(name_error) = name_error {
|
||||||
span class="error" { (name_error) }
|
span class="text-red-600" { (name_error) }
|
||||||
}
|
}
|
||||||
label for="email" { "Password *" }
|
}
|
||||||
|
div {
|
||||||
|
label for="email" class="text-sm font-medium text-gray-700" { "Password *" }
|
||||||
input
|
input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
@ -59,20 +66,25 @@ pub fn register_form(props: RegisterFormProps) -> Markup {
|
|||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
minlength="8"
|
minlength="8"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
required;
|
required
|
||||||
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
@if let Some(password_error) = password_error {
|
@if let Some(password_error) = password_error {
|
||||||
span class="error" { (password_error) }
|
span class="text-red-600" { (password_error) }
|
||||||
}
|
}
|
||||||
label for="password_confirmation" { "Confirm Password *" }
|
}
|
||||||
|
div {
|
||||||
|
label for="password_confirmation" class="text-sm font-medium text-gray-700" { "Confirm Password *" }
|
||||||
input
|
input
|
||||||
type="password"
|
type="password"
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
id="password_confirmation"
|
id="password_confirmation"
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
required;
|
required
|
||||||
button type="submit" { "Submit" }
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
|
}
|
||||||
|
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200"{ "Submit" }
|
||||||
@if let Some(general_error) = general_error {
|
@if let Some(general_error) = general_error {
|
||||||
span class="error" { (general_error) }
|
span class="text-red-600" { (general_error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -22,23 +22,27 @@ pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup {
|
|||||||
method="post"
|
method="post"
|
||||||
hx-post="/reset-password"
|
hx-post="/reset-password"
|
||||||
id="reset-password-form"
|
id="reset-password-form"
|
||||||
class="auth-form-grid"
|
class="my-4 flex flex-col gap-4"
|
||||||
{
|
{
|
||||||
input
|
input
|
||||||
type="text"
|
type="text"
|
||||||
name="token"
|
name="token"
|
||||||
id="token"
|
id="token"
|
||||||
value=(token.to_string())
|
value=(token.to_string())
|
||||||
style="display:none;";
|
class="hidden";
|
||||||
label for="email" { "Email" }
|
div {
|
||||||
|
label for="email" class="text-sm font-medium text-gray-700" { "Email" }
|
||||||
input
|
input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
value=(email)
|
value=(email)
|
||||||
required;
|
required
|
||||||
label for="password" { "Password" }
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
label for="password" class="text-sm font-medium text-gray-700" { "Password" }
|
||||||
input
|
input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
@ -46,20 +50,25 @@ pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup {
|
|||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
minlength="8"
|
minlength="8"
|
||||||
maxlength="255"
|
maxlength="255"
|
||||||
required;
|
required
|
||||||
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
@if let Some(password_error) = password_error {
|
@if let Some(password_error) = password_error {
|
||||||
span class="error" { (password_error) }
|
span class="text-red-600" { (password_error) }
|
||||||
}
|
}
|
||||||
label for="password_confirmation" { "Confirm Password" }
|
}
|
||||||
|
div {
|
||||||
|
label for="password_confirmation" class="text-sm font-medium text-gray-700" { "Confirm Password" }
|
||||||
input
|
input
|
||||||
type="password"
|
type="password"
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
id="password_confirmation"
|
id="password_confirmation"
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
required;
|
required
|
||||||
button type="submit" { "Reset password" }
|
class="w-full mt-1 p-2 bg-gray-50 border border-gray-300 shadow-sm rounded-md focus:ring focus:ring-blue-500 focus:border-blue-500 focus:ring-opacity-50";
|
||||||
|
}
|
||||||
|
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200"{ "Reset password" }
|
||||||
@if let Some(general_error) = general_error {
|
@if let Some(general_error) = general_error {
|
||||||
span class="error" { (general_error) }
|
span class="text-red-600" { (general_error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2
tailwind.config.js
Normal file
2
tailwind.config.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
const config = require('./frontend/tailwind.config.js');
|
||||||
|
export default config;
|
Loading…
Reference in New Issue
Block a user