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

@@ -1,13 +1,6 @@
import { Typewriter } from "./Typewriter"; import { Typewriter } from "./Typewriter";
import { useBars } from "~/context/bars"; import { useBars } from "~/context/bars";
import { import { onMount, createSignal, Show, For, onCleanup } from "solid-js";
onMount,
createSignal,
Show,
For,
onCleanup,
createEffect
} from "solid-js";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { insertSoftHyphens } from "~/lib/client-utils"; import { insertSoftHyphens } from "~/lib/client-utils";
import GitHub from "./icons/GitHub"; import GitHub from "./icons/GitHub";
@@ -102,7 +95,7 @@ export function RightBarContent() {
const handleLinkClick = () => { const handleLinkClick = () => {
if ( if (
typeof window !== "undefined" && typeof window !== "undefined" &&
window.innerWidth < BREAKPOINTS.MOBILE window.innerWidth < BREAKPOINTS.MOBILE_MAX_WIDTH
) { ) {
setLeftBarVisible(false); setLeftBarVisible(false);
} }

View File

@@ -1,6 +1,6 @@
import { createSignal, createEffect, For, Show } from "solid-js"; import { createSignal, createEffect, For, Show } from "solid-js";
import Dropzone from "./Dropzone"; import Dropzone from "./Dropzone";
import XCircle from "~/components/icons/XCircle"; import AttachmentThumbnail from "~/components/ui/AttachmentThumbnail";
import AddImageToS3 from "~/lib/s3upload"; import AddImageToS3 from "~/lib/s3upload";
import { env } from "~/env/client"; import { env } from "~/env/client";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
@@ -147,42 +147,13 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
<div class="-mx-24 grid grid-cols-6 gap-4"> <div class="-mx-24 grid grid-cols-6 gap-4">
<For each={s3Files()}> <For each={s3Files()}>
{(file) => ( {(file) => (
<div> <AttachmentThumbnail
<button fileUrl={getFileUrl(file.key)}
type="button" isVideo={isVideoFile(file.key)}
class="hover:bg-crust hover:bg-opacity-80 absolute z-10 ml-4 pb-[120px]" onCopy={() => copyToClipboard(file.key)}
onClick={() => removeImage(file.key)} onRemove={() => removeImage(file.key)}
> alt="attachment"
<XCircle />
height={24}
width={24}
stroke={"currentColor"}
strokeWidth={1}
/>
</button>
<button
type="button"
onClick={() => copyToClipboard(file.key)}
class="relative"
>
<Show
when={isVideoFile(file.key)}
fallback={
<img
src={getFileUrl(file.key)}
class="mx-4 my-auto h-36 w-36 object-cover"
alt="attachment"
/>
}
>
<video
src={getFileUrl(file.key)}
class="mx-4 my-auto h-36 w-36 object-cover"
controls
/>
</Show>
</button>
</div>
)} )}
</For> </For>
<Show when={newFileHolder().length > 0}> <Show when={newFileHolder().length > 0}>
@@ -190,45 +161,17 @@ export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
</Show> </Show>
<For each={newFileHolder()}> <For each={newFileHolder()}>
{(file, index) => ( {(file, index) => (
<div> <AttachmentThumbnail
<button fileUrl={file}
type="button" isVideo={fileTypes()[index()]?.startsWith("video/") || false}
class="hover:bg-crust hover:bg-opacity-80 absolute z-10 ml-4 pb-[120px]" onCopy={() =>
onClick={() => copyToClipboard(newFileHolderKeys()[index()] as string)
removeNewImage(index(), newFileHolderKeys()[index()]) }
} onRemove={() =>
> removeNewImage(index(), newFileHolderKeys()[index()])
<XCircle }
height={24} alt="new attachment"
width={24} />
stroke={"currentColor"}
strokeWidth={1}
/>
</button>
<button
type="button"
onClick={() =>
copyToClipboard(newFileHolderKeys()[index()] as string)
}
>
<Show
when={fileTypes()[index()]?.startsWith("video/")}
fallback={
<img
src={file}
class="mx-4 my-auto h-36 w-36 object-cover"
alt="new attachment"
/>
}
>
<video
src={file}
class="mx-4 my-auto h-36 w-36 object-cover"
controls
/>
</Show>
</button>
</div>
)} )}
</For> </For>
</div> </div>

View File

@@ -10,7 +10,8 @@ import ReplyIcon from "~/components/icons/ReplyIcon";
import TrashIcon from "~/components/icons/TrashIcon"; import TrashIcon from "~/components/icons/TrashIcon";
import EditIcon from "~/components/icons/EditIcon"; import EditIcon from "~/components/icons/EditIcon";
import ThumbsUpEmoji from "~/components/icons/emojis/ThumbsUp"; import ThumbsUpEmoji from "~/components/icons/emojis/ThumbsUp";
import LoadingSpinner from "~/components/LoadingSpinner"; import Button from "~/components/ui/Button";
import IconButton from "~/components/ui/IconButton";
import CommentInputBlock from "./CommentInputBlock"; import CommentInputBlock from "./CommentInputBlock";
import ReactionBar from "./ReactionBar"; import ReactionBar from "./ReactionBar";
@@ -254,32 +255,46 @@ export default function CommentBlock(props: CommentBlockProps) {
{/* Delete button */} {/* Delete button */}
<Show when={canDelete()}> <Show when={canDelete()}>
<button class="z-100" onClick={deleteCommentTrigger}> <IconButton
<Show icon={
when={!deletionLoading()}
fallback={<LoadingSpinner height={24} width={24} />}
>
<TrashIcon <TrashIcon
height={24} height={24}
width={24} width={24}
stroke="var(--color-red)" stroke="var(--color-red)"
strokeWidth={1.5} strokeWidth={1.5}
/> />
</Show> }
</button> variant="danger"
loading={deletionLoading()}
onClick={deleteCommentTrigger}
aria-label="Delete comment"
class="z-100"
/>
</Show> </Show>
</div> </div>
{/* Edit and Reply buttons */} {/* Edit and Reply buttons */}
<div class="absolute flex"> <div class="absolute flex">
<Show when={canEdit()}> <Show when={canEdit()}>
<button onClick={editCommentTrigger} class="px-2"> <IconButton
<EditIcon strokeWidth={1} height={24} width={24} /> icon={<EditIcon strokeWidth={1} height={24} width={24} />}
</button> onClick={editCommentTrigger}
aria-label="Edit comment"
class="px-2"
/>
</Show> </Show>
<button onClick={toggleCommentReplyBox} class="z-30"> <IconButton
<ReplyIcon color={replyIconColor()} height={24} width={24} /> icon={
</button> <ReplyIcon
color={replyIconColor()}
height={24}
width={24}
/>
}
onClick={toggleCommentReplyBox}
aria-label="Reply to comment"
class="z-30"
/>
</div> </div>
{/* Reaction bar */} {/* Reaction bar */}

View File

@@ -1,5 +1,6 @@
import { createEffect } from "solid-js"; import { createEffect } from "solid-js";
import type { CommentInputBlockProps } from "~/types/comment"; import type { CommentInputBlockProps } from "~/types/comment";
import Button from "~/components/ui/Button";
export default function CommentInputBlock(props: CommentInputBlockProps) { export default function CommentInputBlock(props: CommentInputBlockProps) {
let bodyRef: HTMLTextAreaElement | undefined; let bodyRef: HTMLTextAreaElement | undefined;
@@ -37,17 +38,13 @@ export default function CommentInputBlock(props: CommentInputBlockProps) {
</label> </label>
</div> </div>
<div class="flex justify-end pt-2"> <div class="flex justify-end pt-2">
<button <Button
type="submit" type="submit"
disabled={props.commentSubmitLoading} loading={props.commentSubmitLoading}
class={`${ variant="primary"
props.commentSubmitLoading
? "bg-surface2 opacity-50"
: "border-sapphire bg-blue hover:brightness-125"
} rounded border px-4 py-2 text-base font-light shadow-md transition-all duration-300 ease-in-out active:scale-90`}
> >
Submit Submit
</button> </Button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { createSignal, Show } from "solid-js"; import { createSignal } from "solid-js";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import TrashIcon from "~/components/icons/TrashIcon"; import TrashIcon from "~/components/icons/TrashIcon";
import LoadingSpinner from "~/components/LoadingSpinner"; import Button from "~/components/ui/Button";
export interface DeletePostButtonProps { export interface DeletePostButtonProps {
type: string; type: string;
@@ -28,14 +28,14 @@ export default function DeletePostButton(props: DeletePostButtonProps) {
return ( return (
<form onSubmit={deletePostTrigger} class="flex w-full justify-end"> <form onSubmit={deletePostTrigger} class="flex w-full justify-end">
<button type="submit" class="hover:cursor-pointer"> <Button
<Show type="submit"
when={!loading()} variant="ghost"
fallback={<LoadingSpinner height={24} width={24} />} loading={loading()}
> class="hover:cursor-pointer"
<TrashIcon height={24} width={24} strokeWidth={1.5} /> >
</Show> <TrashIcon height={24} width={24} strokeWidth={1.5} />
</button> </Button>
</form> </form>
); );
} }

View File

@@ -1,6 +1,7 @@
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import type { EditCommentModalProps } from "~/types/comment"; import type { EditCommentModalProps } from "~/types/comment";
import Xmark from "~/components/icons/Xmark"; import Xmark from "~/components/icons/Xmark";
import Button from "~/components/ui/Button";
export default function EditCommentModal(props: EditCommentModalProps) { export default function EditCommentModal(props: EditCommentModalProps) {
let bodyRef: HTMLTextAreaElement | undefined; let bodyRef: HTMLTextAreaElement | undefined;
@@ -52,17 +53,13 @@ export default function EditCommentModal(props: EditCommentModalProps) {
<label class="underlinedInputLabel">Edit Comment</label> <label class="underlinedInputLabel">Edit Comment</label>
</div> </div>
<div class="flex justify-end pt-2"> <div class="flex justify-end pt-2">
<button <Button
type="submit" type="submit"
disabled={props.editCommentLoading} loading={props.editCommentLoading}
class={`${ variant="primary"
props.editCommentLoading
? "bg-surface2 opacity-50"
: "border-sapphire bg-blue hover:brightness-125"
} rounded border px-4 py-2 text-base shadow-md transition-all duration-300 ease-in-out active:scale-90`}
> >
Submit Submit
</button> </Button>
</div> </div>
</form> </form>
<Show when={showNoChange()}> <Show when={showNoChange()}>

View File

@@ -9,6 +9,7 @@ import AddAttachmentSection from "~/components/blog/AddAttachmentSection";
import XCircle from "~/components/icons/XCircle"; import XCircle from "~/components/icons/XCircle";
import AddImageToS3 from "~/lib/s3upload"; import AddImageToS3 from "~/lib/s3upload";
import Input from "~/components/ui/Input"; import Input from "~/components/ui/Input";
import Button from "~/components/ui/Button";
interface PostFormProps { interface PostFormProps {
mode: "create" | "edit"; mode: "create" | "edit";
@@ -542,23 +543,14 @@ export default function PostForm(props: PostFormProps) {
{/* Submit button */} {/* Submit button */}
<div class="flex justify-end"> <div class="flex justify-end">
<button <Button
type="submit" type="submit"
disabled={loading()} loading={loading()}
class={`${ variant={published() ? "primary" : "secondary"}
loading() class="text-crust w-36"
? "bg-surface2 cursor-not-allowed"
: published()
? "bg-peach hover:brightness-125"
: "bg-green hover:brightness-125"
} text-crust flex w-36 justify-center rounded py-3 transition-all duration-300 ease-out active:scale-90`}
> >
{loading() {published() ? "Publish!" : "Save as Draft"}
? "Loading..." </Button>
: published()
? "Publish!"
: "Save as Draft"}
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { For } from "solid-js"; import { For } from "solid-js";
import InfoIcon from "~/components/icons/InfoIcon"; import InfoIcon from "~/components/icons/InfoIcon";
import Xmark from "~/components/icons/Xmark"; import Xmark from "~/components/icons/Xmark";
import IconButton from "~/components/ui/IconButton";
export interface TagMakerProps { export interface TagMakerProps {
tagInputValue: string; tagInputValue: string;
@@ -42,13 +43,20 @@ export default function TagMaker(props: TagMakerProps) {
<div class="overflow-hidden text-base overflow-ellipsis whitespace-nowrap"> <div class="overflow-hidden text-base overflow-ellipsis whitespace-nowrap">
{tag} {tag}
</div> </div>
<button <IconButton
type="button" icon={
class="bg-mantle bg-opacity-50 absolute inset-0 flex items-center justify-center rounded-xl opacity-0 group-hover:opacity-100" <Xmark
strokeWidth={1}
color={"white"}
height={24}
width={24}
/>
}
onClick={() => props.deleteTag(idx())} onClick={() => props.deleteTag(idx())}
> aria-label={`Remove tag ${tag}`}
<Xmark strokeWidth={1} color={"white"} height={24} width={24} /> variant="danger"
</button> class="bg-mantle bg-opacity-50 absolute inset-0 flex items-center justify-center rounded-xl opacity-0 group-hover:opacity-100"
/>
</div> </div>
)} )}
</For> </For>

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 };