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
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:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -63,6 +63,7 @@ export function PlaylistMembershipPanel({
|
||||
{showNewForm
|
||||
? (
|
||||
<PlaylistCreateForm
|
||||
className="animate-in"
|
||||
dumpId={dumpId}
|
||||
onCreated={(playlist) => {
|
||||
onPlaylistCreated({ playlist, hasDump: true });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user