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:
Tyler Hallada 2024-01-07 19:45:20 -05:00
parent 89f37279e5
commit 4eee21caed
37 changed files with 546 additions and 584 deletions

Binary file not shown.

View File

@ -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;
} }

View File

@ -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';

View File

@ -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": {

View 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'),
],
};

View File

@ -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:

View File

@ -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(),
)) ))
} }

View File

@ -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(),
))
} }

View File

@ -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() }))
"." "."
} }
}), }),

View File

@ -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))
} }

View File

@ -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)

View File

@ -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,15 +148,22 @@ 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"
li { "Fetching feed..." } class="overflow-x-hidden whitespace-nowrap text-ellipsis"
} hx-sse=(feed_stream)
hx-swap="beforeend"
hx-target="#add-feed-messages"
{
li { "Fetching feed..." }
} }
} }
.into_string(), .into_string(),
@ -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(),
)), )),

View File

@ -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())
} }

View File

@ -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."
} }
} }

View File

@ -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))
} }
})) }))
} }

View File

@ -33,15 +33,19 @@ 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"
li { "Uploading..."} class="overflow-x-hidden whitespace-nowrap text-ellipsis"
} hx-sse=(import_stream)
hx-swap="beforeend"
hx-target="#import-feeds-messages"
{
li { "Uploading..."}
} }
} }
.into_string(), .into_string(),
@ -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(),
)), )),

View File

@ -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()))
} }
})) }))

View File

@ -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))
} }

View File

@ -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))
} }

View File

@ -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" { 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()
}))
"." "."
} }
} }
@ -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() }))
} }
} }
})) }))

View File

@ -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" }
} }
} }
} }

View File

@ -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 {
input label for="email" class="text-sm font-medium text-gray-700" { "Email" }
type="email" input
name="email" type="email"
id="email" name="email"
placeholder="Email" id="email"
value=(email.unwrap_or_default()) placeholder="Email"
required; value=(email.unwrap_or_default())
button type="submit" { "Resend confirmation email" } 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";
}
button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200"{ "Resend confirmation email" }
} }
} }
} }

View File

@ -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(); }
html! {
a href=(url) class="entry-link" { (title) } em class="entry-link-domain" { (domain) } 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! {
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()
}

View File

@ -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)) }
} }
} }
} }

View File

@ -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,
"Crawling feed...".to_string() pub reset_htmx_target: bool,
} else { }
"Untitled Feed".to_string()
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()
} else {
"Untitled Feed".to_string()
}
});
let feed_url = format!("/feed/{}", Base62Uuid::from(self.feed.feed_id));
html! {
(link(LinkProps { destination: &feed_url, title: &title, reset_htmx_target: self.reset_htmx_target }))
} }
});
let feed_url = format!("/feed/{}", Base62Uuid::from(feed.feed_id));
html! {
a href=(feed_url) { (title) }
} }
} }
pub fn feed_link(feed: &Feed) -> Markup {
FeedLink::new(feed).render()
}

View File

@ -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)) }
} }
} }
} }

View File

@ -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()
})) "."
} }
} }
} }

View File

@ -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 {
input label for="email" class="text-sm font-medium text-gray-700" { "Email" }
type="email" input
name="email" type="email"
id="email" name="email"
placeholder="Email" id="email"
value=(email.unwrap_or_default()) placeholder="Email"
required; value=(email.unwrap_or_default())
@if let Some(email_error) = email_error { 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";
span class="error" { (email_error) } @if let Some(email_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" }
} }
} }
} }

View File

@ -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() }))
} }
} }
} }

View File

@ -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
View 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) }
}
}

View File

@ -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,37 +25,47 @@ 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 {
input label for="email" class="text-sm font-medium text-gray-700" { "Email" }
type="email" input
name="email" type="email"
id="email" name="email"
placeholder="Email" id="email"
value=(email.unwrap_or_default()) placeholder="Email"
required; value=(email.unwrap_or_default())
@if let Some(email_error) = email_error { required
span class="error" { (email_error) } 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 {
span class="text-red-600" { (email_error) }
}
} }
label for="email" { "Password" } div {
input label for="pwassword" class="text-sm font-medium text-gray-700" { "Password" }
type="password" input
name="password" type="password"
id="password" name="password"
placeholder="Password" id="password"
minlength="8" placeholder="Password"
maxlength="255" minlength="8"
required; maxlength="255"
@if let Some(password_error) = password_error { required
span class="error" { (password_error) } 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 {
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,
}))
} }
} }
} }

View File

@ -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;

View File

@ -6,29 +6,32 @@ 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" {}
} }
script { div class="whitespace-nowrap" {
(PreEscaped(r#" button type="submit" class="py-2 px-4 font-medium rounded-md border border-gray-200" { "Import Feeds" }
htmx.on('#opml-import-form', 'htmx:xhr:progress', function (evt) {
htmx.find('#opml-upload-progress').setAttribute(
'value',
evt.detail.loaded / evt.detail.total * 100,
);
});
"#))
} }
} }
progress id="opml-upload-progress" max="100" value="0" hidden="true" {}
script {
(PreEscaped(r#"
htmx.on('#opml-import-form', 'htmx:xhr:progress', function (evt) {
htmx.find('#opml-upload-progress').setAttribute(
'value',
evt.detail.loaded / evt.detail.total * 100,
);
});
"#))
}
} }
} }

View File

@ -27,52 +27,64 @@ 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 {
input label for="email" class="text-sm font-medium text-gray-700" { "Email *" }
type="email" input
name="email" type="email"
id="email" name="email"
placeholder="Email" id="email"
value=(email.unwrap_or_default()) placeholder="Email"
required; value=(email.unwrap_or_default())
@if let Some(email_error) = email_error { required
span class="error" { (email_error) } 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 {
span class="text-red-600" { (email_error) }
}
} }
label for="name" { (PreEscaped("Name &nbsp;")) } div {
input label for="name" class="text-sm font-medium text-gray-700" { (PreEscaped("Name &nbsp;")) }
type="text" input
name="name" type="text"
id="name" name="name"
value=(name.unwrap_or_default()) id="name"
placeholder="Name" value=(name.unwrap_or_default())
maxlength="255"; placeholder="Name"
@if let Some(name_error) = name_error { maxlength="255"
span class="error" { (name_error) } 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 {
span class="text-red-600" { (name_error) }
}
} }
label for="email" { "Password *" } div {
input label for="email" class="text-sm font-medium text-gray-700" { "Password *" }
type="password" input
name="password" type="password"
id="password" name="password"
placeholder="Password" id="password"
minlength="8" placeholder="Password"
maxlength="255" minlength="8"
required; maxlength="255"
@if let Some(password_error) = password_error { required
span class="error" { (password_error) } 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 {
span class="text-red-600" { (password_error) }
}
} }
label for="password_confirmation" { "Confirm Password *" } div {
input label for="password_confirmation" class="text-sm font-medium text-gray-700" { "Confirm Password *" }
type="password" input
name="password_confirmation" type="password"
id="password_confirmation" name="password_confirmation"
placeholder="Confirm Password" id="password_confirmation"
required; placeholder="Confirm Password"
button type="submit" { "Submit" } 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";
}
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) }
} }
} }
} }

View File

@ -22,44 +22,53 @@ 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 {
input label for="email" class="text-sm font-medium text-gray-700" { "Email" }
type="email" input
name="email" type="email"
id="email" name="email"
placeholder="Email" id="email"
value=(email) placeholder="Email"
required; value=(email)
label for="password" { "Password" } required
input 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";
type="password"
name="password"
id="password"
placeholder="Password"
minlength="8"
maxlength="255"
required;
@if let Some(password_error) = password_error {
span class="error" { (password_error) }
} }
label for="password_confirmation" { "Confirm Password" } div {
input label for="password" class="text-sm font-medium text-gray-700" { "Password" }
type="password" input
name="password_confirmation" type="password"
id="password_confirmation" name="password"
placeholder="Confirm Password" id="password"
required; placeholder="Password"
button type="submit" { "Reset password" } minlength="8"
maxlength="255"
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 {
span class="text-red-600" { (password_error) }
}
}
div {
label for="password_confirmation" class="text-sm font-medium text-gray-700" { "Confirm Password" }
input
type="password"
name="password_confirmation"
id="password_confirmation"
placeholder="Confirm Password"
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";
}
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
View File

@ -0,0 +1,2 @@
const config = require('./frontend/tailwind.config.js');
export default config;