From 609932a55bfcae5747dc174754534ac7b1e7a64b Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 6 Jan 2026 10:16:38 -0500 Subject: [PATCH] UI consolidation --- src/components/PageHead.tsx | 49 ++++ src/components/blog/PostForm.tsx | 43 ++-- src/components/ui/Button.tsx | 43 ++-- src/components/ui/Input.tsx | 9 +- src/components/ui/PasswordInput.tsx | 65 +++++ src/lib/client-utils.ts | 29 +++ src/routes/account.tsx | 251 +++++--------------- src/routes/blog/post.css | 3 + src/routes/contact.tsx | 47 ++-- src/routes/downloads.tsx | 82 +++---- src/routes/login/index.tsx | 137 +++-------- src/routes/login/password-reset.tsx | 116 ++------- src/routes/login/request-password-reset.tsx | 26 +- 13 files changed, 383 insertions(+), 517 deletions(-) create mode 100644 src/components/PageHead.tsx create mode 100644 src/components/ui/PasswordInput.tsx diff --git a/src/components/PageHead.tsx b/src/components/PageHead.tsx new file mode 100644 index 0000000..d6fd5ac --- /dev/null +++ b/src/components/PageHead.tsx @@ -0,0 +1,49 @@ +import { Title, Meta, Link } from "@solidjs/meta"; + +export interface PageHeadProps { + title: string; + description?: string; + ogImage?: string; + ogTitle?: string; + ogDescription?: string; + canonical?: string; +} + +/** + * PageHead component for consistent page metadata across the application. + * Automatically appends " | Michael Freno" to the title. + * + * @example + * ```tsx + * + * ``` + */ +export default function PageHead(props: PageHeadProps) { + const fullTitle = () => `${props.title} | Michael Freno`; + + return ( + <> + {fullTitle()} + {props.description && ( + + )} + {props.canonical && } + + {/* Open Graph / Social Media Tags */} + {(props.ogTitle || props.title) && ( + + )} + {(props.ogDescription || props.description) && ( + + )} + {props.ogImage && } + + ); +} diff --git a/src/components/blog/PostForm.tsx b/src/components/blog/PostForm.tsx index 5dd67ba..1c0321c 100644 --- a/src/components/blog/PostForm.tsx +++ b/src/components/blog/PostForm.tsx @@ -8,6 +8,7 @@ import TagMaker from "~/components/blog/TagMaker"; import AddAttachmentSection from "~/components/blog/AddAttachmentSection"; import XCircle from "~/components/icons/XCircle"; import AddImageToS3 from "~/lib/s3upload"; +import Input from "~/components/ui/Input"; interface PostFormProps { mode: "create" | "edit"; @@ -435,32 +436,26 @@ export default function PostForm(props: PostFormProps) {
{/* Title */} -
- setTitle(e.currentTarget.value)} - name="title" - placeholder=" " - class="underlinedInput w-full bg-transparent" - /> - - -
+ setTitle(e.currentTarget.value)} + name="title" + label="Title" + containerClass="input-group mx-4" + class="w-full" + /> {/* Subtitle */} -
- setSubtitle(e.currentTarget.value)} - name="subtitle" - placeholder=" " - class="underlinedInput w-full bg-transparent" - /> - - -
+ setSubtitle(e.currentTarget.value)} + name="subtitle" + label="Subtitle" + containerClass="input-group mx-4" + class="w-full" + /> {/* Banner */}
Banner
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 33e629b..8a765b8 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -1,4 +1,5 @@ import { JSX, splitProps, Show } from "solid-js"; +import LoadingSpinner from "~/components/LoadingSpinner"; export interface ButtonProps extends JSX.ButtonHTMLAttributes { variant?: "primary" | "secondary" | "danger" | "ghost"; @@ -22,18 +23,28 @@ export default function Button(props: ButtonProps) { const size = () => local.size || "md"; const baseClasses = - "flex justify-center items-center rounded font-semibold transition-all duration-300 ease-out disabled:opacity-50 disabled:cursor-not-allowed"; + "flex justify-center items-center rounded transition-all duration-300 ease-out"; const variantClasses = () => { + const isDisabledOrLoading = local.disabled || local.loading; + switch (variant()) { case "primary": - return "bg-blue-400 hover:bg-blue-500 active:scale-90 dark:bg-blue-600 dark:hover:bg-blue-700 text-white shadow-lg shadow-blue-300 dark:shadow-blue-700"; + return isDisabledOrLoading + ? "bg-blue cursor-not-allowed brightness-75" + : "bg-blue hover:brightness-125 active:scale-90"; case "secondary": - return "bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-900 dark:text-white"; + return isDisabledOrLoading + ? "bg-surface0 cursor-not-allowed brightness-75" + : "bg-surface0 hover:brightness-125 active:scale-90"; case "danger": - return "bg-red-500 hover:bg-red-600 active:scale-90 text-white shadow-lg shadow-red-300 dark:shadow-red-700"; + return isDisabledOrLoading + ? "bg-red cursor-not-allowed brightness-75" + : "bg-red hover:brightness-125 active:scale-90"; case "ghost": - return "bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"; + return isDisabledOrLoading + ? "cursor-not-allowed opacity-50" + : "hover:brightness-125 active:scale-90"; default: return ""; } @@ -61,27 +72,7 @@ export default function Button(props: ButtonProps) { class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${widthClass()} ${local.class || ""}`} > - - - - - Loading... + ); diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index ecfb879..6e72404 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -4,6 +4,8 @@ export interface InputProps extends JSX.InputHTMLAttributes { label?: string; error?: string; helperText?: string; + containerClass?: string; + ref?: HTMLInputElement | ((el: HTMLInputElement) => void); } export default function Input(props: InputProps) { @@ -11,13 +13,16 @@ export default function Input(props: InputProps) { "label", "error", "helperText", - "class" + "class", + "containerClass", + "ref" ]); return ( -
+
{ + showStrength?: boolean; + defaultVisible?: boolean; + passwordValue?: string; +} + +export default function PasswordInput(props: PasswordInputProps) { + const [local, inputProps] = splitProps(props, [ + "showStrength", + "defaultVisible", + "passwordValue", + "class", + "containerClass" + ]); + + const [showPassword, setShowPassword] = createSignal( + local.defaultVisible || false + ); + + return ( + <> +
+ + + +
+ + {local.showStrength && local.passwordValue !== undefined && ( +
+ +
+ )} + + ); +} diff --git a/src/lib/client-utils.ts b/src/lib/client-utils.ts index a9c9c00..98eae26 100644 --- a/src/lib/client-utils.ts +++ b/src/lib/client-utils.ts @@ -72,3 +72,32 @@ export function insertSoftHyphens( }) .join(" "); } + +export function glitchText( + originalText: string, + setter: (text: string) => void, + glitchInterval: number = 300, + glitchLength: number = 80 +) { + const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`"; + + const interval = setInterval(() => { + if (Math.random() > 0.9) { + let glitched = ""; + for (let i = 0; i < originalText.length; i++) { + if (Math.random() > 0.8) { + glitched += + glitchChars[Math.floor(Math.random() * glitchChars.length)]; + } else { + glitched += originalText[i]; + } + } + setter(glitched); + + setTimeout(() => { + setter(originalText); + }, glitchLength); + } + }, glitchInterval); + return interval; +} diff --git a/src/routes/account.tsx b/src/routes/account.tsx index 1054f2b..753b0e1 100644 --- a/src/routes/account.tsx +++ b/src/routes/account.tsx @@ -2,8 +2,6 @@ import { createSignal, Show, createEffect } from "solid-js"; import { Title, Meta } from "@solidjs/meta"; import { useNavigate, redirect, query, createAsync } from "@solidjs/router"; import { getEvent } from "vinxi/http"; -import Eye from "~/components/icons/Eye"; -import EyeSlash from "~/components/icons/EyeSlash"; import XCircle from "~/components/icons/XCircle"; import GoogleLogo from "~/components/icons/GoogleLogo"; import GitHub from "~/components/icons/GitHub"; @@ -14,6 +12,9 @@ import { validatePassword, isValidEmail } from "~/lib/validation"; import { TerminalSplash } from "~/components/TerminalSplash"; import { VALIDATION_CONFIG } from "~/config"; import { api } from "~/lib/api"; +import Input from "~/components/ui/Input"; +import PasswordInput from "~/components/ui/PasswordInput"; +import Button from "~/components/ui/Button"; import type { UserProfile } from "~/types/user"; import PasswordStrengthMeter from "~/components/PasswordStrengthMeter"; @@ -87,10 +88,6 @@ export default function AccountPage() { const [passwordDeletionError, setPasswordDeletionError] = createSignal(false); const [newPassword, setNewPassword] = createSignal(""); - const [showOldPasswordInput, setShowOldPasswordInput] = createSignal(false); - const [showPasswordInput, setShowPasswordInput] = createSignal(false); - const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false); - const [showImageSuccess, setShowImageSuccess] = createSignal(false); const [showEmailSuccess, setShowEmailSuccess] = createSignal(false); const [showDisplayNameSuccess, setShowDisplayNameSuccess] = @@ -662,25 +659,19 @@ export default function AccountPage() { JavaScript required to update email
-
- - - -
+ -
- - - -
+
-
+
-
- - - -
- -
- -
-
- - - - -
+ +
-
- - - -
+
+ ); +}; export default function DownloadsPage() { - const [glitchText, setGlitchText] = createSignal("$ downloads"); + const [LaLText, setLaLText] = createSignal("Life and Lineage"); + const [SwAText, setSwAText] = createSignal("Shapes with Abigail!"); + const [corkText, setCorkText] = createSignal("Cork"); const download = (assetName: string) => { fetch(`/api/downloads/public/${assetName}`) @@ -17,30 +37,14 @@ export default function DownloadsPage() { }; onMount(() => { - const originalText = "$ downloads"; - const glitchChars = "!@#$%^&*()_+-=[]{}|;':\",./<>?~`"; - - const glitchInterval = setInterval(() => { - if (Math.random() > 0.9) { - let glitched = ""; - for (let i = 0; i < originalText.length; i++) { - if (Math.random() > 0.8) { - glitched += - glitchChars[Math.floor(Math.random() * glitchChars.length)]; - } else { - glitched += originalText[i]; - } - } - setGlitchText(glitched); - - setTimeout(() => { - setGlitchText(originalText); - }, 80); - } - }, 300); + const lalInterval = glitchText(LaLText(), setLaLText); + const swaInterval = glitchText(SwAText(), setSwAText); + const corkInterval = glitchText(corkText(), setCorkText); onCleanup(() => { - clearInterval(glitchInterval); + clearInterval(lalInterval); + clearInterval(swaInterval); + clearInterval(corkInterval); }); }); @@ -65,18 +69,11 @@ export default function DownloadsPage() {
-
- freno@downloads - : - ~ - {glitchText()} -
-
{/* Life and Lineage */}

- {">"} Life and Lineage + {">"} {LaLText()}

@@ -84,12 +81,9 @@ export default function DownloadsPage() { platform: android - + # android build not optimized @@ -112,7 +106,7 @@ export default function DownloadsPage() { {/* Shapes with Abigail */}

- {">"} Shapes with Abigail! + {">"} {SwAText()}

@@ -120,12 +114,11 @@ export default function DownloadsPage() { platform: android - +
@@ -145,19 +138,16 @@ export default function DownloadsPage() { {/* Cork */}

- {">"} Cork + {">"} {corkText()}

platform: macOS (13+) - + # unzip → drag to /Applications diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index ff71bfb..a0b0043 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -10,14 +10,14 @@ import { Title, Meta } from "@solidjs/meta"; import { getEvent } from "vinxi/http"; import GoogleLogo from "~/components/icons/GoogleLogo"; import GitHub from "~/components/icons/GitHub"; -import Eye from "~/components/icons/Eye"; -import EyeSlash from "~/components/icons/EyeSlash"; import CountdownCircleTimer from "~/components/CountdownCircleTimer"; import PasswordStrengthMeter from "~/components/PasswordStrengthMeter"; import { isValidEmail, validatePassword } from "~/lib/validation"; import { getClientCookie } from "~/lib/cookies.client"; import { env } from "~/env/client"; import { VALIDATION_CONFIG, COUNTDOWN_CONFIG } from "~/config"; +import Input from "~/components/ui/Input"; +import PasswordInput from "~/components/ui/PasswordInput"; const checkAuth = query(async () => { "use server"; @@ -49,8 +49,6 @@ export default function LoginPage() { const [emailSent, setEmailSent] = createSignal(false); const [showPasswordError, setShowPasswordError] = createSignal(false); const [showPasswordSuccess, setShowPasswordSuccess] = createSignal(false); - const [showPasswordInput, setShowPasswordInput] = createSignal(false); - const [showPasswordConfInput, setShowPasswordConfInput] = createSignal(false); const [passwordsMatch, setPasswordsMatch] = createSignal(false); const [password, setPassword] = createSignal(""); const [passwordConf, setPasswordConf] = createSignal(""); @@ -402,116 +400,47 @@ export default function LoginPage() {
-
- - - -
+
-
- - - -
- -
-
- - -
- +
-
- - - -
- +
+
+ +
{ const target = e.target as HTMLInputElement; + setNewPassword(target.value); checkPasswordLength(target.value); if (newPasswordConfRef) { checkForMatch(target.value, newPasswordConfRef.value); @@ -169,49 +169,19 @@ export default function PasswordResetPage() { >
-
- - - -
- +
-
- - - -
- +
-
- - - -
+ 0}