UI consolidation
This commit is contained in:
49
src/components/PageHead.tsx
Normal file
49
src/components/PageHead.tsx
Normal file
@@ -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
|
||||
* <PageHead
|
||||
* title="Blog"
|
||||
* description="Technical blog posts about web development"
|
||||
* ogImage="https://example.com/og-image.jpg"
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export default function PageHead(props: PageHeadProps) {
|
||||
const fullTitle = () => `${props.title} | Michael Freno`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title>{fullTitle()}</Title>
|
||||
{props.description && (
|
||||
<Meta name="description" content={props.description} />
|
||||
)}
|
||||
{props.canonical && <Link rel="canonical" href={props.canonical} />}
|
||||
|
||||
{/* Open Graph / Social Media Tags */}
|
||||
{(props.ogTitle || props.title) && (
|
||||
<Meta property="og:title" content={props.ogTitle || props.title} />
|
||||
)}
|
||||
{(props.ogDescription || props.description) && (
|
||||
<Meta
|
||||
property="og:description"
|
||||
content={props.ogDescription || props.description}
|
||||
/>
|
||||
)}
|
||||
{props.ogImage && <Meta property="og:image" content={props.ogImage} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
<form onSubmit={handleSubmit} class="w-full max-w-full px-4">
|
||||
<div class="mx-auto w-full md:w-3/4 xl:w-1/2">
|
||||
{/* Title */}
|
||||
<div class="input-group mx-4">
|
||||
<input
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||
name="title"
|
||||
placeholder=" "
|
||||
class="underlinedInput w-full bg-transparent"
|
||||
/>
|
||||
<span class="bar"></span>
|
||||
<label class="underlinedInputLabel">Title</label>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||
name="title"
|
||||
label="Title"
|
||||
containerClass="input-group mx-4"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div class="input-group mx-4">
|
||||
<input
|
||||
type="text"
|
||||
value={subtitle()}
|
||||
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
||||
name="subtitle"
|
||||
placeholder=" "
|
||||
class="underlinedInput w-full bg-transparent"
|
||||
/>
|
||||
<span class="bar"></span>
|
||||
<label class="underlinedInputLabel">Subtitle</label>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
value={subtitle()}
|
||||
onInput={(e) => setSubtitle(e.currentTarget.value)}
|
||||
name="subtitle"
|
||||
label="Subtitle"
|
||||
containerClass="input-group mx-4"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
{/* Banner */}
|
||||
<div class="pt-8 text-center text-xl">Banner</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { JSX, splitProps, Show } from "solid-js";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
|
||||
export interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
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 || ""}`}
|
||||
>
|
||||
<Show when={local.loading} fallback={local.children}>
|
||||
<svg
|
||||
class="mr-2 h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Loading...
|
||||
<LoadingSpinner height={24} width={24} />
|
||||
</Show>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface InputProps extends JSX.InputHTMLAttributes<HTMLInputElement> {
|
||||
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 (
|
||||
<div class="input-group">
|
||||
<div class={local.containerClass || "input-group"}>
|
||||
<input
|
||||
{...others}
|
||||
ref={local.ref}
|
||||
placeholder=" "
|
||||
class={`underlinedInput bg-transparent ${local.class || ""}`}
|
||||
aria-invalid={!!local.error}
|
||||
|
||||
65
src/components/ui/PasswordInput.tsx
Normal file
65
src/components/ui/PasswordInput.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { JSX, splitProps, createSignal, Show } from "solid-js";
|
||||
import Input, { InputProps } from "./Input";
|
||||
import Eye from "~/components/icons/Eye";
|
||||
import EyeSlash from "~/components/icons/EyeSlash";
|
||||
import PasswordStrengthMeter from "~/components/PasswordStrengthMeter";
|
||||
|
||||
export interface PasswordInputProps extends Omit<InputProps, "type"> {
|
||||
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 (
|
||||
<>
|
||||
<div class={local.containerClass || "input-group relative mx-4 mb-2"}>
|
||||
<Input
|
||||
{...inputProps}
|
||||
type={showPassword() ? "text" : "password"}
|
||||
class={`w-full pr-10 ${local.class || ""}`}
|
||||
containerClass=""
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword())}
|
||||
class="text-subtext0 absolute top-2 right-0 transition-all hover:brightness-125"
|
||||
aria-label={showPassword() ? "Hide password" : "Show password"}
|
||||
>
|
||||
<Show
|
||||
when={showPassword()}
|
||||
fallback={
|
||||
<EyeSlash
|
||||
height={24}
|
||||
width={24}
|
||||
strokeWidth={1}
|
||||
class="stroke-text"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Eye height={24} width={24} strokeWidth={1} class="stroke-text" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{local.showStrength && local.passwordValue !== undefined && (
|
||||
<div class="px-4 pt-1">
|
||||
<PasswordStrengthMeter password={local.passwordValue} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user