v3: inline edit forms and the narrow-viewport search row ease open instead of popping in
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-19 20:26:18 +00:00
parent 5e5f984d87
commit 8c3efe6c30
15 changed files with 202 additions and 28 deletions

View File

@@ -117,14 +117,23 @@ export function AppHeader(
</nav>
</header>
{searchExpanded && (
<div className="header-search-below">
<SearchBar
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
{/* Stays mounted so the row can slide open and shut; `inert` keeps the
collapsed input out of the tab order. */}
<div
className={`header-search-reveal${
searchExpanded ? " header-search-reveal--open" : ""
}`}
inert={!searchExpanded}
>
<div className="header-search-reveal-clip">
<div className="header-search-below">
<SearchBar
expanded={searchExpanded}
onExpandedChange={setSearchExpanded}
/>
</div>
</div>
)}
</div>
{wsStatus === "disconnected" && wsErrorMessage && (
<div className="app-header-status" role="alert">

View File

@@ -15,6 +15,7 @@ import { Markdown } from "./Markdown.tsx";
import { Avatar } from "./Avatar.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
import { Tooltip } from "./Tooltip.tsx";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { can } from "../utils/permissions.ts";
@@ -68,6 +69,7 @@ function ChatMessageItem(
}: ChatMessageItemProps,
) {
const [editing, setEditing] = useState(false);
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
const [editDraft, setEditDraft] = useState(message.body);
const [confirmDelete, setConfirmDelete] = useState(false);
const editorRef = useRef<TextEditorHandle>(null);
@@ -102,7 +104,7 @@ function ChatMessageItem(
hasAvatar={!!message.authorAvatarMime}
size={32}
/>
<div className="chat-message-body">
<div className="chat-message-body" ref={bodyRef}>
{message.replyToId && (
// Subtle one-line reference to the replied-to message; clicking jumps
// to it. Falls back to a muted note when the target was deleted.
@@ -192,7 +194,7 @@ function ChatMessageItem(
{editing
? (
<div className="chat-edit">
<div className="chat-edit animate-in">
<TextEditor
ref={editorRef}
value={editDraft}

View File

@@ -20,6 +20,7 @@ import { LikeButton } from "./LikeButton.tsx";
import { relativeTime } from "../utils/relativeTime.ts";
import { Tooltip } from "./Tooltip.tsx";
import { ConfirmModal } from "./ConfirmModal.tsx";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts";
import {
expectOk,
@@ -51,6 +52,7 @@ interface CommentFormProps {
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
layout?: "inline" | "top";
leading?: React.ReactNode;
className?: string;
}
/**
@@ -69,6 +71,7 @@ function CommentForm({
editorRef,
layout = "inline",
leading,
className,
}: CommentFormProps) {
const form = useApiForm<{ body: string }>({
defaultValues: { body: defaultValue },
@@ -140,7 +143,10 @@ function CommentForm({
</form>
)
: (
<form className="comment-form" onSubmit={submit}>
<form
className={className ? `comment-form ${className}` : "comment-form"}
onSubmit={submit}
>
{inner}
</form>
)}
@@ -194,6 +200,9 @@ function CommentNode({
const [replyOpen, setReplyOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const innerRef = useHeightTransition<HTMLDivElement>(
`${editOpen}:${replyOpen}`,
);
const replyEditorRef = useRef<TextEditorHandle>(null);
const editEditorRef = useRef<TextEditorHandle>(null);
@@ -284,7 +293,11 @@ function CommentNode({
return (
<li className="comment-node">
<div className="comment-node-inner" id={`comment-${comment.id}`}>
<div
className="comment-node-inner"
id={`comment-${comment.id}`}
ref={innerRef}
>
<div className="comment-avatar">
<Avatar
userId={comment.userId}
@@ -328,6 +341,7 @@ function CommentNode({
submitLabel={<Trans>Save</Trans>}
pendingLabel={<Trans>Saving…</Trans>}
errorTitle={t`Failed to save edit`}
className="animate-in"
onCancel={() => setEditOpen(false)}
/>
)
@@ -395,6 +409,7 @@ function CommentNode({
submitLabel={<Trans>Post reply</Trans>}
pendingLabel={<Trans>Posting…</Trans>}
errorTitle={t`Failed to post reply`}
className="animate-in"
placeholder={t`Write a reply…`}
onCancel={() => setReplyOpen(false)}
/>

View File

@@ -8,6 +8,7 @@ interface ImagePickerProps {
onChange: (file: File) => void;
uploading?: boolean;
accept?: string;
className?: string;
}
export function ImagePicker({
@@ -18,6 +19,7 @@ export function ImagePicker({
onChange,
uploading = false,
accept = "image/jpeg,image/png,image/gif,image/webp",
className,
}: ImagePickerProps) {
const inputRef = useRef<HTMLInputElement>(null);
const sizeStyle = { width: size, height: size, borderRadius };
@@ -30,7 +32,7 @@ export function ImagePicker({
return (
<div
className="img-picker"
className={className ? `img-picker ${className}` : "img-picker"}
style={sizeStyle}
onClick={() => !uploading && inputRef.current?.click()}
title={src ? "Change image" : "Add image"}

View File

@@ -21,6 +21,7 @@ interface PlaylistCreateFormProps {
dumpId?: string;
onCreated: (playlist: Playlist) => void;
onCancel: () => void;
className?: string;
}
interface Values {
@@ -30,7 +31,7 @@ interface Values {
}
export function PlaylistCreateForm(
{ dumpId, onCreated, onCancel }: PlaylistCreateFormProps,
{ dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps,
) {
const { authFetch } = useAuth();
const form = useApiForm<Values>({
@@ -64,7 +65,10 @@ export function PlaylistCreateForm(
return (
<FormProvider {...form}>
<form className="form" onSubmit={onSubmit}>
<form
className={className ? `form ${className}` : "form"}
onSubmit={onSubmit}
>
<TextField<Values>
name="title"
placeholder={t`Title`}

View File

@@ -63,6 +63,7 @@ export function PlaylistMembershipPanel({
{showNewForm
? (
<PlaylistCreateForm
className="animate-in"
dumpId={dumpId}
onCreated={(playlist) => {
onPlaylistCreated({ playlist, hasDump: true });

View File

@@ -22,13 +22,25 @@ export function SearchBar(
() => new URLSearchParams(location.search).get("q") ?? "",
);
// Collapsing always discards the draft, however it was collapsed (the ✕ in
// the header toggles `expanded` from outside).
const [prevExpanded, setPrevExpanded] = useState(expanded);
if (prevExpanded !== expanded) {
setPrevExpanded(expanded);
if (!expanded) setValue("");
}
function setExpanded(v: boolean) {
if (!isControlled) setExpandedState(v);
onExpandedChange?.(v);
}
useEffect(() => {
if ((collapsible || isControlled) && expanded) inputRef.current?.focus();
if ((collapsible || isControlled) && expanded) {
// No scroll-into-view: the below-header row is still clipped while it
// slides open, and scrolling the clip would offset its content.
inputRef.current?.focus({ preventScroll: true });
}
}, [expanded, collapsible, isControlled]);
function handleIconClick() {