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 {
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 {
.list-loading {
display: none;
}
.htmx-request .list-loading {
display: block;
display: inline;
}
.htmx-request.list-loading {
display: block;
}
.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;
display: inline;
}

View File

@ -1,7 +1,7 @@
import htmx from 'htmx.org';
// import assets so they get named with a content hash that busts caches
import '../css/styles.css';
// import '../css/styles.css';
import './localTimeController';

View File

@ -2,6 +2,8 @@
"name": "crawlnicle-frontend",
"module": "js/index.ts",
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"@typescript-eslint/eslint-plugin": "^6.5.0",
"@typescript-eslint/parser": "^6.5.0",
"bun-types": "^1.0.3",
@ -13,6 +15,7 @@
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-promise": "^6.1.1",
"prettier": "^3.0.3",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2"
},
"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/*
build-frontend: clean-frontend
bunx tailwindcss -i frontend/css/styles.css -o static/css/styles.css --minify
bun build frontend/js/index.ts \
--outdir ./static \
--root ./frontend \
@ -22,6 +23,7 @@ build-frontend: clean-frontend
touch .frontend-built # trigger build.rs to run
build-dev-frontend: clean-frontend
bunx tailwindcss -i frontend/css/styles.css -o static/css/styles.css
bun build frontend/js/index.ts \
--outdir ./static \
--root ./frontend \
@ -38,14 +40,19 @@ watch-frontend: install-frontend
cargo watch -w frontend \
-s 'just build-dev-frontend'
build-dev-backend: build-dev-frontend
cargo run
watch-backend:
mold -run cargo watch \
--ignore 'logs/*' \
--ignore 'static/*' \
--ignore 'frontend/*' \
--ignore 'content' \
--ignore 'content/*' \
--no-vcs-ignores \
-x run
--why \
-s 'just build-dev-backend'
# runs watch-frontend and watch-backend simultaneously
watch:

View File

@ -22,6 +22,6 @@ pub async fn get(
}
}
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));
}
}
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::partials::confirm_email_form::{confirm_email_form, ConfirmEmailFormProps};
use crate::partials::layout::Layout;
use crate::partials::link::{link, LinkProps};
use crate::uuid::Base62Uuid;
#[derive(Deserialize)]
@ -48,16 +49,18 @@ pub fn confirm_email_page(
.with_subtitle("confirm email")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { (header.unwrap_or("Confirm your email address")) }
div class="w-fit mx-auto" {
header class="text-center" {
h2 class="mb-4 text-2xl font-medium" {
(header.unwrap_or("Confirm your email address"))
}
}
@if let Some(desc) = desc {
(desc)
} @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 "
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"),
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."
}
}),
@ -115,13 +118,13 @@ pub async fn get(
.with_subtitle("confirm email")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Your email is now confirmed!" }
div class="w-fit mx-auto" {
header class="text-center" {
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. "
a href="/" { "Return home" }
(link(LinkProps { destination: "/", title: "Return home", ..Default::default() }))
}
}
}))
@ -170,11 +173,11 @@ pub async fn post(
.with_subtitle("confirm email")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Resent confirmation email" }
div class="w-fit mx-auto" {
header class="text-center" {
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."
}
}
@ -193,11 +196,11 @@ pub async fn post(
.with_subtitle("confirm email")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Resent confirmation email" }
div class="w-fit mx-auto" {
header class="text-center" {
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."
}
}
@ -209,9 +212,9 @@ pub async fn post(
form_props: ConfirmEmailFormProps::default(),
header: Some("Email verification token not found"),
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 "
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>,
) -> Result<Markup> {
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)
.targeted(hx_target)
.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 {
h2 class="title" { a href=(entry.url) { (title) } }
h2 class="mb-4 text-2xl font-medium" {
a href=(entry.url) { (title) }
}
}
div {
span class="published" {
span class="text-sm text-gray-600" {
strong { "Published: " }
time datetime=(published_at) class="local-time" {
(published_at)

View File

@ -41,17 +41,20 @@ pub async fn get(
let entries = Entry::get_all(&pool, &options).await?;
let delete_url = format!("/feed/{}/delete", id);
Ok(layout.with_subtitle(&title).targeted(hx_target).render(html! {
header class="feed-header" {
h2 { (title) }
button class="edit-feed" { "✏️ Edit feed" }
header class="mb-4 flex flex-row items-center gap-4" {
h2 class="text-2xl font-medium" { (title) }
button class="py-2 px-4 font-medium rounded-md border border-gray-200" { "✏️ Edit feed" }
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 {
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(),
html! {
(add_feed_form())
ul class="stream-messages" {
li { span class="error" { (self) } }
ul class="overflow-x-hidden whitespace-nowrap text-ellipsis" {
li { span class="text-red-600" { (self) } }
}
}
.into_string(),
@ -145,17 +148,24 @@ pub async fn post(
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((
StatusCode::CREATED,
html! {
(add_feed_form())
div hx-sse=(feed_stream) {
ul class="stream-messages" hx-sse="swap:message" hx-swap="beforeend" {
ul
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..." }
}
}
}
.into_string(),
)
.into_response())
@ -178,7 +188,7 @@ pub async fn stream(
Ok::<Event, String>(
Event::default().data(
html! {
li { "Crawled feed: " (feed_link(&feed, false)) }
li hx-target="#main-content" hx-swap="innerHTML" { "Crawled feed: " (feed_link(&feed)) }
}
.into_string(),
),
@ -188,7 +198,7 @@ pub async fn stream(
entry,
)))) => Ok(Event::default().data(
html! {
li { "Crawled entry: " (entry_link(&entry)) }
li hx-target="#main-content" hx-swap="innerHTML" { "Crawled entry: " (entry_link(&entry)) }
}
.into_string(),
)),
@ -196,7 +206,7 @@ pub async fn stream(
error,
)))) => Ok(Event::default().data(
html! {
li id=(feed_id) { span class="error" { (error) } }
li id=(feed_id) { span class="text-red-600" { (error) } }
}
.into_string(),
)),
@ -204,13 +214,13 @@ pub async fn stream(
error,
)))) => Ok(Event::default().data(
html! {
li { span class="error" { (error) } }
li { span class="text-red-600" { (error) } }
}
.into_string(),
)),
Ok(CrawlSchedulerHandleMessage::Schedule(Err(error))) => Ok(Event::default().data(
html! {
li { span class="error" { (error) } }
li { span class="text-red-600" { (error) } }
}
.into_string(),
)),

View File

@ -23,13 +23,13 @@ pub async fn get(
.with_subtitle("feeds")
.targeted(hx_target)
.render(html! {
header { h2 { "Feeds" } }
div class="feeds" {
ul id="feeds" {
(feed_list(feeds, &options))
header { h2 class="mb-4 text-2xl font-medium" { "Feeds" } }
div class="flex flex-col gap-6 lg:flex-row md:justify-between" {
ul id="feed-list" class="list-none flex flex-col gap-4" {
(feed_list(feeds, &options, true))
}
div class="add-feed" {
h3 { "Add Feed" }
div class="flex flex-col gap-6 max-w-md" {
h3 class="text-xl font-medium" { "Add Feed" }
(add_feed_form())
(opml_import_form())
}

View File

@ -33,11 +33,11 @@ pub fn forgot_password_page(
.with_subtitle("forgot password")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Forgot Password" }
div class="w-fit mx-auto" {
header class="text-center" {
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" } "."
}
(forgot_password_form(form_props))
@ -54,11 +54,11 @@ pub fn confirm_forgot_password_sent_page(
.with_subtitle("forgot password")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Reset password email sent" }
div class="w-fit mx-auto" {
header class="text-center" {
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."
}
}

View File

@ -17,8 +17,8 @@ pub async fn get(
let options = Default::default();
let entries = Entry::get_all(&pool, &options).await?;
Ok(layout.targeted(hx_target).render(html! {
ul class="entries" {
(entry_list(entries, &options))
ul class="list-none flex flex-col gap-4" {
(entry_list(entries, &options, true))
}
}))
}

View File

@ -33,17 +33,21 @@ pub async fn opml(
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((
StatusCode::CREATED,
html! {
(opml_import_form())
div hx-sse=(import_stream) {
ul class="stream-messages" hx-sse="swap:message" hx-swap="beforeend" {
ul
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..."}
}
}
}
.into_string(),
)
.into_response());
@ -76,7 +80,7 @@ pub async fn stream(
))) => Ok::<Event, String>(
Event::default().data(
html! {
li { "Crawled entry: " (entry_link(&entry)) }
li hx-target="#main-content" hx-swap="innerHTML" { "Crawled entry: " (entry_link(&entry)) }
}
.into_string(),
),
@ -86,7 +90,7 @@ pub async fn stream(
))) => Ok::<Event, String>(
Event::default().data(
html! {
li { "Crawled feed: " (feed_link(&feed, false)) }
li hx-target="#main-content" hx-swap="innerHTML" { "Crawled feed: " (feed_link(&feed)) }
}
.into_string(),
),
@ -96,7 +100,7 @@ pub async fn stream(
))) => Ok::<Event, String>(
Event::default().data(
html! {
li { span class="error" { (error) } }
li { span class="text-red-600" { (error) } }
}
.into_string(),
),
@ -106,7 +110,7 @@ pub async fn stream(
))) => Ok::<Event, String>(
Event::default().data(
html! {
li { span class="error" { (error) } }
li { span class="text-red-600" { (error) } }
}
.into_string(),
),
@ -116,7 +120,7 @@ pub async fn stream(
)))) => Ok::<Event, String>(
Event::default().data(
html! {
li { span class="error" { (error) } }
li { span class="text-red-600" { (error) } }
}
.into_string(),
),
@ -125,7 +129,7 @@ pub async fn stream(
Event::default().data(
html! {
li {
span class="error" {
span class="text-red-600" {
"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(
html! {
li { span class="error" { (error) } }
li { span class="text-red-600" { (error) } }
}
.into_string(),
)),

View File

@ -28,7 +28,7 @@ pub async fn get(hx_target: Option<TypedHeader<HXTarget>>, layout: Layout) -> Re
.with_subtitle("log")
.targeted(hx_target)
.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()))
}
}))

View File

@ -43,9 +43,9 @@ pub fn login_page(
.with_subtitle("login")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Login" }
div class="w-fit mx-auto" {
header class="text-center" {
h2 class="mb-4 text-2xl font-medium" { "Login" }
}
(login_form(form_props))
}

View File

@ -41,9 +41,9 @@ pub fn register_page(
.with_subtitle("register")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Register" }
div class="w-fit mx-auto" {
header class="text-center" {
h2 class="mb-4 text-2xl font-medium" { "Register" }
}
(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::models::user::UpdateUserPassword;
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::uuid::Base62Uuid;
use crate::{models::user::User, partials::layout::Layout};
@ -56,17 +57,21 @@ pub fn invalid_token_page(
.with_subtitle("reset password")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { (header.unwrap_or("Reset Password")) }
div class="w-fit mx-auto" {
header class="text-center" {
h2 class="mb-4 text-2xl font-medium" {
(header.unwrap_or("Reset Password"))
}
}
@if let Some(desc) = desc {
p class="readable-width" { (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" { (desc) }
}
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")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { (header.unwrap_or("Reset Password")) }
div class="w-fit mx-auto" {
header class="text-center" {
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" } "."
}
(reset_password_form(form_props))
@if let Some(post_form_error) = post_form_error {
p class="error readable-width" { (post_form_error) }
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 text-red-600" { (post_form_error) }
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()
}))
". The link in the email will be valid for 24 hours."
}
}
@ -305,13 +314,13 @@ pub async fn post(
.with_subtitle("reset password")
.targeted(hx_target)
.render(html! {
div class="center-horizontal" {
header class="center-text" {
h2 { "Password reset!" }
div class="w-fit mx-auto" {
header class="text-center" {
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. "
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-target="#add-feed-form"
hx-swap="outerHTML"
class="feed-form"
class="flex flex-row gap-6 items-end justify-between"
{
div class="form-grid" {
label for="url" { "URL: " }
// TODO: make into an input partial component
div class="grow w-full" {
label for="url" class="text-sm font-medium text-gray-700" { "URL" }
input
type="text"
id="url"
name="url"
placeholder="https://example.com/feed.xml"
required="true";
button type="submit" { "Add Feed" }
required="true"
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"
hx-post="/confirm-email"
id="confirm-email-form"
class="auth-form-grid"
class="my-4 flex flex-col gap-4"
{
input
type="text"
name="token"
id="token"
value=(token.map(|t| t.token_id.to_string()).unwrap_or_default())
style="display:none;";
label for="email" { "Email" }
class="hidden";
div {
label for="email" class="text-sm font-medium text-gray-700" { "Email" }
input
type="email"
name="email"
id="email"
placeholder="Email"
value=(email.unwrap_or_default())
required;
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 crate::models::entry::Entry;
use crate::partials::link::{link, LinkProps};
use crate::utils::get_domain;
use crate::uuid::Base62Uuid;
pub fn entry_link(entry: &Entry) -> Markup {
let title = entry.title.as_ref().map(|s| s.clone()).unwrap_or_else(|| "Untitled".to_string());
let url = format!("/entry/{}", Base62Uuid::from(entry.entry_id));
let domain = get_domain(&entry.url).unwrap_or_default();
pub struct EntryLink<'a> {
pub entry: &'a Entry,
pub reset_htmx_target: bool,
}
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! {
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()
}

View File

@ -1,29 +1,27 @@
use maud::{html, Markup};
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;
if len == 0 {
if first_page && len == 0 {
return html! { p { "No entries found." } };
}
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();
if let Some(feed_id) = options.feed_id {
more_query = Some(format!(
"/api/v1/entries?feed_id={}&published_before={}&id_before={}",
feed_id,
last_entry.published_at,
last_entry.entry_id
"/api/v1/entries?feed_id={}&published_before={}&id_before={}&limit={}",
feed_id, last_entry.published_at, last_entry.entry_id, limit
));
} else {
more_query = Some(format!(
"/api/v1/entries?published_before={}&id_before={}",
last_entry.published_at,
last_entry.entry_id
"/api/v1/entries?published_before={}&id_before={}&limit={}",
last_entry.published_at, last_entry.entry_id, limit
));
}
}
@ -32,17 +30,17 @@ pub fn entry_list(entries: Vec<Entry>, options: &GetEntriesOptions) -> Markup {
@for (i, entry) in entries.iter().enumerate() {
@if i == entries.len() - 1 {
@if let Some(ref more_query) = more_query {
li class="entry" hx-get=(more_query) hx-trigger="revealed" hx-swap="afterend" {
(entry_link(entry))
div class="htmx-indicator list-loading" {
img class="loading" src="/static/img/three-dots.svg" alt="Loading...";
li hx-get=(more_query) hx-trigger="revealed" hx-target="this" hx-swap="afterend" {
(EntryLink::new(entry).reset_htmx_target().render())
div class="list-loading" {
img class="mt-4 max-h-4 invert" src="/static/img/three-dots.svg" alt="Loading...";
}
}
} @else {
li class="entry" { (entry_link(entry)) }
li { (entry_link(entry)) }
}
} @else {
li class="entry" { (entry_link(entry)) }
li { (entry_link(entry)) }
}
}
}

View File

@ -1,18 +1,49 @@
use maud::{html, Markup};
use crate::models::feed::Feed;
use crate::partials::link::{link, LinkProps};
use crate::uuid::Base62Uuid;
pub fn feed_link(feed: &Feed, pending_crawl: bool) -> Markup {
let title = feed.title.clone().unwrap_or_else(|| {
if pending_crawl {
pub struct FeedLink<'a> {
pub feed: &'a Feed,
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()
} else {
"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! {
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()
}

View File

@ -1,21 +1,21 @@
use maud::{html, Markup};
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;
if len == 0 {
if first_page && len == 0 {
return html! { p { "No feeds found." } };
}
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();
more_query = Some(format!(
"/api/v1/feeds?sort=CreatedAt&before={}&id_before={}",
last_feed.created_at,
last_feed.feed_id
"/api/v1/feeds?sort=CreatedAt&before={}&id_before={}&limit={}",
last_feed.created_at, last_feed.feed_id, limit
));
}
@ -23,17 +23,17 @@ pub fn feed_list(feeds: Vec<Feed>, options: &GetFeedsOptions) -> Markup {
@for (i, feed) in feeds.iter().enumerate() {
@if i == feeds.len() - 1 {
@if let Some(ref more_query) = more_query {
li class="feed" hx-get=(more_query) hx-trigger="revealed" hx-swap="afterend" {
(feed_link(feed, false))
div class="htmx-indicator list-loading" {
img class="loading" src="/static/img/three-dots.svg" alt="Loading...";
li hx-get=(more_query) hx-trigger="revealed" hx-target="this" hx-swap="afterend" {
(FeedLink::new(feed).reset_htmx_target().render())
div class="list-loading" {
img class="mt-4 max-h-4 invert" src="/static/img/three-dots.svg" alt="Loading...";
}
}
} @else {
li class="feed" { (feed_link(feed, false)) }
li { (feed_link(feed)) }
}
} @else {
li class="feed" { (feed_link(feed, false)) }
li { (feed_link(feed)) }
}
}
}

View File

@ -1,10 +1,16 @@
use maud::{html, Markup};
use crate::partials::link::{link, LinkProps};
pub fn footer() -> Markup {
html! {
footer class="footer" {
hr;
"Made by " a href="https://www.hallada.net" { "Tyler Hallada" }"."
footer class="text-center mt-16 mb-2" {
hr class="w-12 mx-auto mb-4";
"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"
hx-post="/forgot-password"
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
type="email"
name="email"
id="email"
placeholder="Email"
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 {
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" }
}
}
}

View File

@ -1,31 +1,40 @@
use maud::{html, Markup};
use crate::models::user::User;
use crate::partials::link::{home_link, link, LinkProps};
use crate::partials::user_name::user_name;
pub fn header(title: &str, user: Option<User>) -> Markup {
html! {
header class="header" {
nav {
h1 { a href="/" { (title) } }
ul {
li { a href="/feeds" { "feeds" } }
li { a href="/log" { "log" } }
header {
nav class="flex flex-row items-baseline justify-between" {
div class="flex flex-row items-baseline gap-4" {
h1 {
(home_link(LinkProps {
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" {
@if let Some(user) = user {
(user_name(user.clone()))
@if !user.email_verified {
span { " (" }
a href="/confirm-email" { "unverified" }
(link(LinkProps { destination: "/confirm-email", title: "unverified", ..Default::default() }))
span { ")" }
}
span { " | " }
a href="/logout" { "logout" }
(link(LinkProps { destination: "/logout", title: "logout", ..Default::default() }))
} @else {
a href="/login" { "login" }
(link(LinkProps { destination: "/login", title: "login", ..Default::default() }))
span { " | " }
a href="/register" { "register" }
(link(LinkProps { destination: "/register", title: "register", ..Default::default() }))
}
}
}

View File

@ -154,6 +154,7 @@ impl Layout {
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { (self.full_title()) }
@for js_file in js_manifest() {
script type="module" src=(js_file) {}
@ -162,9 +163,9 @@ impl Layout {
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))
main id="main-content" { (template) }
main id="main-content" class="my-6 mx-2 md:mx-4" { (template) }
(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 crate::partials::link::{link, LinkProps};
#[derive(Debug, Default)]
pub struct LoginFormProps {
pub email: Option<String>,
@ -23,20 +25,24 @@ pub fn login_form(props: LoginFormProps) -> Markup {
hx-target="#login-form"
hx-swap="outerHTML"
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
type="email"
name="email"
id="email"
placeholder="Email"
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 {
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
type="password"
name="password"
@ -44,16 +50,22 @@ pub fn login_form(props: LoginFormProps) -> Markup {
placeholder="Password"
minlength="8"
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 {
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 {
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" {
"Forgot password"
div class="ml-auto" {
(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 header;
pub mod layout;
pub mod link;
pub mod login_form;
pub mod opml_import_form;
pub mod register_form;

View File

@ -6,19 +6,23 @@ pub fn opml_import_form() -> Markup {
id="opml-import-form"
hx-post="/import/opml"
hx-encoding="multipart/form-data"
class="feed-form"
class="flex flex-row gap-6 items-end justify-between"
{
div class="form-grid" {
label for="opml" { "OPML: " }
div class="grow w-full" {
label for="opml" class="text-sm font-medium text-gray-700" { "OPML" }
input
type="file"
id="opml"
name="opml"
required="true"
accept="text/x-opml,application/xml,text/xml";
button type="submit" { "Import Feeds" }
progress id="opml-upload-progress" max="100" value="0" hidden="true" {}
accept="text/x-opml,application/xml,text/xml"
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" {
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 {
(PreEscaped(r#"
htmx.on('#opml-import-form', 'htmx:xhr:progress', function (evt) {
@ -31,4 +35,3 @@ pub fn opml_import_form() -> Markup {
}
}
}
}

View File

@ -27,31 +27,38 @@ pub fn register_form(props: RegisterFormProps) -> Markup {
hx-target="#register-form"
hx-swap="outerHTML"
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
type="email"
name="email"
id="email"
placeholder="Email"
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 {
span class="error" { (email_error) }
span class="text-red-600" { (email_error) }
}
label for="name" { (PreEscaped("Name &nbsp;")) }
}
div {
label for="name" class="text-sm font-medium text-gray-700" { (PreEscaped("Name &nbsp;")) }
input
type="text"
name="name"
id="name"
value=(name.unwrap_or_default())
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 {
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
type="password"
name="password"
@ -59,20 +66,25 @@ pub fn register_form(props: RegisterFormProps) -> Markup {
placeholder="Password"
minlength="8"
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 {
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
type="password"
name="password_confirmation"
id="password_confirmation"
placeholder="Confirm Password"
required;
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 {
span class="error" { (general_error) }
span class="text-red-600" { (general_error) }
}
}
}

View File

@ -22,23 +22,27 @@ pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup {
method="post"
hx-post="/reset-password"
id="reset-password-form"
class="auth-form-grid"
class="my-4 flex flex-col gap-4"
{
input
type="text"
name="token"
id="token"
value=(token.to_string())
style="display:none;";
label for="email" { "Email" }
class="hidden";
div {
label for="email" class="text-sm font-medium text-gray-700" { "Email" }
input
type="email"
name="email"
id="email"
placeholder="Email"
value=(email)
required;
label for="password" { "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";
}
div {
label for="password" class="text-sm font-medium text-gray-700" { "Password" }
input
type="password"
name="password"
@ -46,20 +50,25 @@ pub fn reset_password_form(props: ResetPasswordFormProps) -> Markup {
placeholder="Password"
minlength="8"
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 {
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
type="password"
name="password_confirmation"
id="password_confirmation"
placeholder="Confirm Password"
required;
button type="submit" { "Reset 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 {
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;