Compare commits

...

2 Commits

Author SHA1 Message Date
khannurien
8c3efe6c30 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>
2026-09-19 20:26:18 +00:00
khannurien
5e5f984d87 v3: journal image cards give the resting vote button white-on-glass ink so it reads over the scrim
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:17:56 +00:00
15 changed files with 216 additions and 28 deletions

View File

@@ -2619,7 +2619,34 @@ body.has-player .chat-fab {
}
}
/* Below-header search row — small viewports only */
/* Below-header search row — small viewports only. The reveal wrapper eases
its single grid track between 0fr and 1fr so the row (theme borders and
all) slides open and shut; the clip keeps the collapsed track at zero. */
.header-search-reveal {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows var(--dur-reveal) var(--ease-out),
opacity var(--dur-reveal) var(--ease-out);
}
.header-search-reveal--open {
grid-template-rows: 1fr;
opacity: 1;
}
.header-search-reveal-clip {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.header-search-reveal {
transition: none;
}
}
.header-search-below {
display: flex;
align-items: center;
@@ -2637,7 +2664,7 @@ body.has-player .chat-fab {
}
@media (min-width: 860px) {
.header-search-below {
.header-search-reveal {
display: none;
}
}
@@ -5356,6 +5383,20 @@ body.has-player .chat-fab {
color: rgba(255, 255, 255, 0.78);
}
/* The resting vote button is transparent and inks itself in --color-text,
which in a light palette (or a muted dark one) sinks into the black scrim.
Over an image it takes the overlay's own white-on-glass treatment; the
voted state keeps its solid accent fill, which already reads. */
.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active) {
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
background: rgba(0, 0, 0, 0.35);
}
.journal-card--image .journal-card-overlay .vote-btn:not(.vote-btn--active):hover:not(:disabled) {
color: var(--color-accent);
border-color: var(--color-accent);
}
.journal-card--image .journal-card-comment {
font-size: var(--text-sm);
line-height: 1.4;

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() {

View File

@@ -0,0 +1,68 @@
import { useCallback, useLayoutEffect, useRef } from "react";
const FALLBACK_MS = 250;
/**
* Eases an element's height whenever `watch` changes, so an interaction that
* swaps part of its content (a view for an edit form, a reply box opening…)
* moves the surrounding layout smoothly instead of jumping.
*
* Returns a callback ref for the element whose height should transition.
* Only the box is animated; give the content that appears the `animate-in`
* class so it fades in as the box grows. Duration follows `--dur-reveal`.
*/
export function useHeightTransition<T extends HTMLElement>(watch: unknown) {
const elRef = useRef<T | null>(null);
// Last rendered height, kept current through a ResizeObserver so that by
// the time the swap commits we still know where the box *was* — including
// mid-animation, when a second toggle should start from the current frame.
const lastHeight = useRef<number | null>(null);
const lastWatch = useRef(watch);
const ref = useCallback((el: T | null) => {
if (!el) return;
elRef.current = el;
lastHeight.current = el.getBoundingClientRect().height;
const ro = new ResizeObserver(() => {
lastHeight.current = el.getBoundingClientRect().height;
});
ro.observe(el);
return () => {
ro.disconnect();
elRef.current = null;
lastHeight.current = null;
};
}, []);
useLayoutEffect(() => {
if (Object.is(lastWatch.current, watch)) return;
lastWatch.current = watch;
const el = elRef.current;
const from = lastHeight.current;
if (!el || from === null) return;
for (const a of el.getAnimations()) {
if (a.id === "height-transition") a.cancel();
}
const to = el.getBoundingClientRect().height;
lastHeight.current = to;
if (
Math.abs(from - to) < 1 ||
matchMedia("(prefers-reduced-motion: reduce)").matches
) return;
const style = getComputedStyle(el);
const duration = parseFloat(style.getPropertyValue("--dur-reveal")) *
1000 || FALLBACK_MS;
const easing = style.getPropertyValue("--ease-out").trim() || "ease-out";
el.animate(
[
{ height: `${from}px`, boxSizing: "border-box", overflow: "clip" },
{ height: `${to}px`, boxSizing: "border-box", overflow: "clip" },
],
{ duration, easing, id: "height-transition" },
);
}, [watch]);
return ref;
}

View File

@@ -65,3 +65,23 @@ button:hover {
::selection {
background: color-mix(in srgb, var(--color-accent) 30%, transparent);
}
/* Content revealed by an inline toggle (edit form, reply box…) eases in as
its container's height grows via useHeightTransition. Runs once, on mount.
No fill mode, so no lingering transform traps fixed-position popovers. */
.animate-in {
animation: animate-in var(--dur-reveal) var(--ease-out);
}
@keyframes animate-in {
from {
opacity: 0;
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.animate-in {
animation: none;
}
}

View File

@@ -30,6 +30,7 @@ import { useAuth } from "../hooks/useAuth.ts";
import { useCategories } from "../hooks/useCategories.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts";
import { Avatar } from "../components/Avatar.tsx";
import RichContentCard from "../components/RichContentCard.tsx";
@@ -74,6 +75,7 @@ export function Dump() {
const [collections, setCollections] = useState<Playlist[]>([]);
const [titleEditing, setTitleEditing] = useState(false);
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
const [titleDraft, setTitleDraft] = useState("");
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState<string | null>(null);
@@ -364,10 +366,10 @@ export function Dump() {
</button>
)}
</div>
<div className="dump-header-right">
<div className="dump-header-right" ref={headerRightRef}>
{titleEditing
? (
<div className="dump-title-editor">
<div className="dump-title-editor animate-in">
<input
type="text"
className="dump-title-input"

View File

@@ -28,6 +28,7 @@ import { playlistUrl } from "../utils/urls.ts";
import { can } from "../utils/permissions.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { useWS } from "../hooks/useWS.ts";
import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx";
@@ -105,6 +106,7 @@ export function PlaylistDetail() {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [editOpen, setEditOpen] = useState(false);
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
const [confirmDelete, setConfirmDelete] = useState(false);
// Mirrors activeDumpIds for use in effects without adding it as a dep.
@@ -587,7 +589,7 @@ export function PlaylistDetail() {
return (
<PageShell>
<div className="playlist-detail-header">
<div className="playlist-detail-header" ref={headerRef}>
<div className="playlist-detail-header-top">
{editOpen
? (
@@ -842,6 +844,7 @@ function PlaylistEditForm(
: null)}
alt="Cover"
size={72}
className="animate-in"
onChange={(file) => {
field.onChange(file);
setImagePreview(URL.createObjectURL(file));
@@ -850,7 +853,7 @@ function PlaylistEditForm(
)}
/>
<div className="playlist-detail-content playlist-edit-content">
<div className="playlist-detail-content playlist-edit-content animate-in">
<Controller
name="title"
control={form.control}

View File

@@ -11,6 +11,7 @@ import {
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { PageShell } from "../components/PageShell.tsx";
import {
expectOk,
@@ -35,6 +36,7 @@ export function UserLogin() {
const navigate = useNavigate();
const { login } = useAuth();
const [showReset, setShowReset] = useState(false);
const cardRef = useHeightTransition<HTMLDivElement>(showReset);
const form = useApiForm<LoginValues>({
defaultValues: { username: "", password: "" },
@@ -54,7 +56,7 @@ export function UserLogin() {
return (
<PageShell centered>
<div className="auth-card">
<div className="auth-card" ref={cardRef}>
<h1 className="auth-card-title">
<Trans>Log in</Trans>
</h1>
@@ -115,7 +117,7 @@ function ResetRequestPanel() {
if (sent) {
return (
<div className="auth-reset-panel">
<div className="auth-reset-panel animate-in">
<p className="auth-reset-sent">
<Trans>
If that address is registered you'll receive a reset link shortly.
@@ -126,7 +128,7 @@ function ResetRequestPanel() {
}
return (
<div className="auth-reset-panel">
<div className="auth-reset-panel animate-in">
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form auth-reset-form">
<FormError title={t`Request failed`} />

View File

@@ -45,6 +45,7 @@ import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
@@ -327,6 +328,8 @@ export function UserPublicProfile() {
const [descEditing, setDescEditing] = useState(false);
const [emailEditing, setEmailEditing] = useState(false);
const infoRef = useHeightTransition<HTMLDivElement>(emailEditing);
const descRef = useHeightTransition<HTMLDivElement>(descEditing);
const prevMyVotesRef = useRef<Set<string> | null>(null);
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
@@ -786,7 +789,7 @@ export function UserPublicProfile() {
</label>
)}
</div>
<div className="profile-info">
<div className="profile-info" ref={infoRef}>
<div className="profile-info-scroll">
<div className="profile-username-row">
<h1 className="profile-username">{profileUser.username}</h1>
@@ -856,7 +859,7 @@ export function UserPublicProfile() {
</div>
{(profileUser.description || isOwnProfile) && (
<div className="profile-description">
<div className="profile-description" ref={descRef}>
{descEditing
? (
<DescriptionEditor
@@ -1646,7 +1649,7 @@ function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
return (
<FormProvider {...form}>
<form className="profile-email-editor" onSubmit={onSubmit}>
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
<input
type="email"
className="profile-email-input"
@@ -1712,7 +1715,10 @@ function DescriptionEditor(
return (
<FormProvider {...form}>
<form className="profile-description-editor" onSubmit={onSubmit}>
<form
className="profile-description-editor animate-in"
onSubmit={onSubmit}
>
<Controller
name="description"
control={form.control}

View File

@@ -85,6 +85,7 @@
--dur-fast: 0.12s;
--dur-base: 0.15s;
--dur-slow: 0.2s;
--dur-reveal: 0.25s;
/* ── Layers ── */
--z-raised: 1;