working in nojs

This commit is contained in:
Michael Freno
2025-12-22 21:18:43 -05:00
parent 33383381db
commit d19ba67153
3 changed files with 210 additions and 18 deletions

View File

@@ -168,7 +168,7 @@ function AppLayout(props: { children: any }) {
<LeftBar /> <LeftBar />
<div class="bg-base relative h-screen w-screen overflow-x-hidden overflow-y-scroll md:ml-62.5 md:w-[calc(100vw-500px)]"> <div class="bg-base relative h-screen w-screen overflow-x-hidden overflow-y-scroll md:ml-62.5 md:w-[calc(100vw-500px)]">
<noscript> <noscript>
<div class="bg-yellow text-crust border-text fixed top-0 z-150 ml-16 border-b-2 p-4 text-center font-semibold md:ml-64"> <div class="bg-yellow text-crust border-text fixed top-0 z-150 border-b-2 p-4 text-center font-semibold md:w-[calc(100vw-500px)]">
JavaScript is disabled. Features will be limited. JavaScript is disabled. Features will be limited.
</div> </div>
</noscript> </noscript>

View File

@@ -357,7 +357,7 @@ export function LeftBar() {
<SkeletonText class="mt-1.5 h-6 w-2/3" /> <SkeletonText class="mt-1.5 h-6 w-2/3" />
</div> </div>
</div> </div>
<SkeletonText class="mt-1.5 h-6 w-1/2" /> <SkeletonText class="mt-1.5 h-6 w-40" />
<SkeletonText class="mt-1.5 h-4 w-1/2" /> <SkeletonText class="mt-1.5 h-4 w-1/2" />
</div> </div>
)} )}

View File

@@ -1,25 +1,167 @@
import { createSignal, onMount, onCleanup, Show } from "solid-js"; import { createSignal, onMount, onCleanup, Show } from "solid-js";
import { useSearchParams } from "@solidjs/router"; import {
useSearchParams,
useNavigate,
useLocation,
query,
createAsync
} from "@solidjs/router";
import { Title, Meta } from "@solidjs/meta"; import { Title, Meta } from "@solidjs/meta";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
import { action, redirect } from "@solidjs/router";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import GitHub from "~/components/icons/GitHub";
import LinkedIn from "~/components/icons/LinkedIn";
import { getClientCookie, setClientCookie } from "~/lib/cookies.client"; import { getClientCookie, setClientCookie } from "~/lib/cookies.client";
import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import CountdownCircleTimer from "~/components/CountdownCircleTimer";
import LoadingSpinner from "~/components/LoadingSpinner"; import LoadingSpinner from "~/components/LoadingSpinner";
import RevealDropDown from "~/components/RevealDropDown"; import RevealDropDown from "~/components/RevealDropDown";
import type { UserProfile } from "~/types/user"; import type { UserProfile } from "~/types/user";
import { getCookie, setCookie } from "vinxi/http";
import { z } from "zod";
import { env } from "~/env/server";
import {
fetchWithTimeout,
checkResponse,
fetchWithRetry,
NetworkError,
TimeoutError,
APIError
} from "~/server/fetch-utils";
const getContactData = query(async () => {
"use server";
const contactExp = getCookie("contactRequestSent");
let remainingTime = 0;
if (contactExp) {
const expires = new Date(contactExp);
remainingTime = Math.max(0, (expires.getTime() - Date.now()) / 1000);
}
return { remainingTime };
}, "contact-data");
// Server action for form submission
const sendContactEmail = action(async (formData: FormData) => {
"use server";
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// Validate inputs
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Valid email is required"),
message: z
.string()
.min(1, "Message is required")
.max(500, "Message too long")
});
try {
schema.parse({ name, email, message });
} catch (err: any) {
return redirect(
`/contact?error=${encodeURIComponent(err.errors[0]?.message || "Invalid input")}`
);
}
// Check rate limit
const contactExp = getCookie("contactRequestSent");
if (contactExp) {
const expires = new Date(contactExp);
const remaining = expires.getTime() - Date.now();
if (remaining > 0) {
return redirect(
"/contact?error=Please wait before sending another message"
);
}
}
// Send email
const apiKey = env.SENDINBLUE_KEY;
const apiUrl = "https://api.sendinblue.com/v3/smtp/email";
const sendinblueData = {
sender: {
name: "freno.me",
email: "michael@freno.me"
},
to: [{ email: "michael@freno.me" }],
htmlContent: `<html><head></head><body><div>Request Name: ${name}</div><div>Request Email: ${email}</div><div>Request Message: ${message}</div></body></html>`,
subject: "freno.me Contact Request"
};
try {
await fetchWithRetry(
async () => {
const response = await fetchWithTimeout(apiUrl, {
method: "POST",
headers: {
accept: "application/json",
"api-key": apiKey,
"content-type": "application/json"
},
body: JSON.stringify(sendinblueData),
timeout: 15000
});
await checkResponse(response);
return response;
},
{
maxRetries: 2,
retryDelay: 1000
}
);
// Set cooldown cookie
const exp = new Date(Date.now() + 1 * 60 * 1000);
setCookie("contactRequestSent", exp.toUTCString(), {
expires: exp,
path: "/"
});
return redirect("/contact?success=true");
} catch (error) {
let errorMessage =
"Failed to send message. You can reach me at michael@freno.me";
if (error instanceof TimeoutError) {
errorMessage =
"Email service timed out. Please try again or contact michael@freno.me";
} else if (error instanceof NetworkError) {
errorMessage =
"Network error. Please try again or contact michael@freno.me";
} else if (error instanceof APIError) {
errorMessage =
"Email service error. You can reach me at michael@freno.me";
}
return redirect(`/contact?error=${encodeURIComponent(errorMessage)}`);
}
});
export default function ContactPage() { export default function ContactPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const viewer = () => searchParams.viewer ?? "default"; const viewer = () => searchParams.viewer ?? "default";
// Load server data using createAsync
const contactData = createAsync(() => getContactData(), {
deferStream: true
});
const [countDown, setCountDown] = createSignal<number>(0); const [countDown, setCountDown] = createSignal<number>(0);
const [emailSent, setEmailSent] = createSignal<boolean>(false); const [emailSent, setEmailSent] = createSignal<boolean>(
const [error, setError] = createSignal<string>(""); searchParams.success === "true"
);
const [error, setError] = createSignal<string>(
searchParams.error ? decodeURIComponent(searchParams.error) : ""
);
const [loading, setLoading] = createSignal<boolean>(false); const [loading, setLoading] = createSignal<boolean>(false);
const [user, setUser] = createSignal<UserProfile | null>(null); const [user, setUser] = createSignal<UserProfile | null>(null);
const [jsEnabled, setJsEnabled] = createSignal<boolean>(false);
let timerIdRef: ReturnType<typeof setInterval> | null = null; let timerIdRef: ReturnType<typeof setInterval> | null = null;
@@ -39,6 +181,14 @@ export default function ContactPage() {
}; };
onMount(() => { onMount(() => {
setJsEnabled(true);
// Initialize countdown from server data
const serverData = contactData();
if (serverData?.remainingTime) {
setCountDown(serverData.remainingTime);
}
// Check for existing timer // Check for existing timer
const timer = getClientCookie("contactRequestSent"); const timer = getClientCookie("contactRequestSent");
if (timer) { if (timer) {
@@ -57,6 +207,20 @@ export default function ContactPage() {
// User not authenticated, no problem // User not authenticated, no problem
}); });
// Clear URL params after reading them (for better UX on refresh)
if (searchParams.success || searchParams.error) {
const timer = setTimeout(() => {
const newUrl =
location.pathname +
(viewer() !== "default" ? `?viewer=${viewer()}` : "");
navigate(newUrl, { replace: true });
setEmailSent(false);
setError("");
}, 5000);
onCleanup(() => clearTimeout(timer));
}
onCleanup(() => { onCleanup(() => {
if (timerIdRef !== null) { if (timerIdRef !== null) {
clearInterval(timerIdRef); clearInterval(timerIdRef);
@@ -64,7 +228,11 @@ export default function ContactPage() {
}); });
}); });
// Progressive enhancement: JS-enhanced form submission
const sendEmailTrigger = async (e: Event) => { const sendEmailTrigger = async (e: Event) => {
// Only intercept if JS is enabled
if (!jsEnabled()) return;
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement); const formData = new FormData(e.target as HTMLFormElement);
@@ -74,6 +242,9 @@ export default function ContactPage() {
if (name && email && message) { if (name && email && message) {
setLoading(true); setLoading(true);
setError("");
setEmailSent(false);
try { try {
const res = await api.misc.sendContactRequest.mutate({ const res = await api.misc.sendContactRequest.mutate({
name, name,
@@ -84,6 +255,8 @@ export default function ContactPage() {
if (res.message === "email sent") { if (res.message === "email sent") {
setEmailSent(true); setEmailSent(true);
setError(""); setError("");
(e.target as HTMLFormElement).reset();
const timer = getClientCookie("contactRequestSent"); const timer = getClientCookie("contactRequestSent");
if (timer) { if (timer) {
if (timerIdRef !== null) { if (timerIdRef !== null) {
@@ -205,7 +378,12 @@ export default function ContactPage() {
</div> </div>
</Show> </Show>
<LineageQuestionsDropDown /> <LineageQuestionsDropDown />
<form onSubmit={sendEmailTrigger} class="w-full"> <form
onSubmit={sendEmailTrigger}
method="post"
action={sendContactEmail}
class="w-full"
>
<div class="flex w-full flex-col justify-evenly"> <div class="flex w-full flex-col justify-evenly">
<div class="mx-auto w-full justify-evenly md:flex md:flex-row"> <div class="mx-auto w-full justify-evenly md:flex md:flex-row">
<div class="input-group md:mx-4"> <div class="input-group md:mx-4">
@@ -244,6 +422,7 @@ export default function ContactPage() {
title="Please enter your message" title="Please enter your message"
class="underlinedInput w-full bg-transparent" class="underlinedInput w-full bg-transparent"
rows={4} rows={4}
maxlength={500}
/> />
<span class="bar" /> <span class="bar" />
<label class="underlinedInputLabel">Message</label> <label class="underlinedInputLabel">Message</label>
@@ -251,7 +430,9 @@ export default function ContactPage() {
</div> </div>
<div class="mx-auto flex w-full justify-end pt-4"> <div class="mx-auto flex w-full justify-end pt-4">
<Show <Show
when={countDown() > 0} when={
countDown() > 0 || (contactData()?.remainingTime ?? 0) > 0
}
fallback={ fallback={
<button <button
type="submit" type="submit"
@@ -267,6 +448,16 @@ export default function ContactPage() {
</Show> </Show>
</button> </button>
} }
>
<Show
when={jsEnabled()}
fallback={
<div class="flex items-center justify-center text-sm text-zinc-400">
Please wait{" "}
{Math.ceil(contactData()?.remainingTime ?? 0)}s before
sending another message
</div>
}
> >
<CountdownCircleTimer <CountdownCircleTimer
duration={60} duration={60}
@@ -279,6 +470,7 @@ export default function ContactPage() {
{renderTime} {renderTime}
</CountdownCircleTimer> </CountdownCircleTimer>
</Show> </Show>
</Show>
</div> </div>
</div> </div>
</form> </form>