continued UI consolidation

This commit is contained in:
Michael Freno
2026-01-06 11:46:15 -05:00
parent b81c73a6bc
commit 46f1efcd79
10 changed files with 234 additions and 146 deletions

View File

@@ -0,0 +1,60 @@
import { Show } from "solid-js";
import type { JSX } from "solid-js";
import IconButton from "./IconButton";
import XCircle from "~/components/icons/XCircle";
export interface AttachmentThumbnailProps {
/** The URL of the file (either S3 URL or data URL) */
fileUrl: string;
/** Whether the file is a video */
isVideo: boolean;
/** Callback when the copy button is clicked */
onCopy: () => void;
/** Callback when the remove button is clicked */
onRemove: () => void;
/** Alt text for the image */
alt?: string;
/** Additional CSS classes */
class?: string;
}
export default function AttachmentThumbnail(props: AttachmentThumbnailProps) {
return (
<div class={props.class}>
<IconButton
icon={
<XCircle
height={24}
width={24}
stroke={"currentColor"}
strokeWidth={1}
/>
}
onClick={props.onRemove}
aria-label={`Remove ${props.alt || "attachment"}`}
variant="danger"
class="hover:bg-crust hover:bg-opacity-80 absolute z-10 ml-4 pb-[120px]"
/>
<button type="button" onClick={props.onCopy} class="relative">
<Show
when={props.isVideo}
fallback={
<img
src={props.fileUrl}
class="mx-4 my-auto h-36 w-36 object-cover"
alt={props.alt || "attachment"}
/>
}
>
<video
src={props.fileUrl}
class="mx-4 my-auto h-36 w-36 object-cover"
controls
/>
</Show>
</button>
</div>
);
}
export { AttachmentThumbnail };

View File

@@ -0,0 +1,83 @@
import { JSX, splitProps, Show } from "solid-js";
import { Spinner } from "~/components/Spinner";
export interface IconButtonProps extends Omit<
JSX.ButtonHTMLAttributes<HTMLButtonElement>,
"children"
> {
icon: JSX.Element;
"aria-label": string;
variant?: "ghost" | "danger" | "primary";
size?: "sm" | "md" | "lg";
loading?: boolean;
}
export default function IconButton(props: IconButtonProps) {
const [local, others] = splitProps(props, [
"icon",
"aria-label",
"variant",
"size",
"loading",
"disabled",
"class"
]);
const variant = () => local.variant || "ghost";
const size = () => local.size || "md";
const baseClasses =
"inline-flex items-center justify-center rounded transition-all duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue focus-visible:ring-offset-2";
const variantClasses = () => {
const isDisabledOrLoading = local.disabled || local.loading;
switch (variant()) {
case "ghost":
return isDisabledOrLoading
? "cursor-not-allowed opacity-50"
: "text-text hover:bg-surface0/50 active:scale-95";
case "danger":
return isDisabledOrLoading
? "cursor-not-allowed opacity-50"
: "text-red hover:bg-red/10 active:scale-95";
case "primary":
return isDisabledOrLoading
? "cursor-not-allowed opacity-50"
: "text-blue hover:bg-blue/10 active:scale-95";
default:
return "";
}
};
const sizeClasses = () => {
switch (size()) {
case "sm":
return "p-1";
case "md":
return "p-2";
case "lg":
return "p-3";
default:
return "";
}
};
return (
<button
{...others}
type="button"
disabled={local.disabled || local.loading}
aria-label={local["aria-label"]}
aria-busy={local.loading}
aria-disabled={local.disabled}
class={`${baseClasses} ${variantClasses()} ${sizeClasses()} ${local.class || ""}`}
>
<Show when={local.loading} fallback={local.icon}>
<Spinner size={20} />
</Show>
</button>
);
}
export { IconButton };