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:
31
src/App.css
31
src/App.css
@@ -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 {
|
.header-search-below {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2637,7 +2664,7 @@ body.has-player .chat-fab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 860px) {
|
@media (min-width: 860px) {
|
||||||
.header-search-below {
|
.header-search-reveal {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,14 +117,23 @@ export function AppHeader(
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{searchExpanded && (
|
{/* 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">
|
<div className="header-search-below">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
expanded={searchExpanded}
|
expanded={searchExpanded}
|
||||||
onExpandedChange={setSearchExpanded}
|
onExpandedChange={setSearchExpanded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{wsStatus === "disconnected" && wsErrorMessage && (
|
{wsStatus === "disconnected" && wsErrorMessage && (
|
||||||
<div className="app-header-status" role="alert">
|
<div className="app-header-status" role="alert">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Markdown } from "./Markdown.tsx";
|
|||||||
import { Avatar } from "./Avatar.tsx";
|
import { Avatar } from "./Avatar.tsx";
|
||||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||||
import { Tooltip } from "./Tooltip.tsx";
|
import { Tooltip } from "./Tooltip.tsx";
|
||||||
|
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { can } from "../utils/permissions.ts";
|
import { can } from "../utils/permissions.ts";
|
||||||
@@ -68,6 +69,7 @@ function ChatMessageItem(
|
|||||||
}: ChatMessageItemProps,
|
}: ChatMessageItemProps,
|
||||||
) {
|
) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const bodyRef = useHeightTransition<HTMLDivElement>(editing);
|
||||||
const [editDraft, setEditDraft] = useState(message.body);
|
const [editDraft, setEditDraft] = useState(message.body);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const editorRef = useRef<TextEditorHandle>(null);
|
const editorRef = useRef<TextEditorHandle>(null);
|
||||||
@@ -102,7 +104,7 @@ function ChatMessageItem(
|
|||||||
hasAvatar={!!message.authorAvatarMime}
|
hasAvatar={!!message.authorAvatarMime}
|
||||||
size={32}
|
size={32}
|
||||||
/>
|
/>
|
||||||
<div className="chat-message-body">
|
<div className="chat-message-body" ref={bodyRef}>
|
||||||
{message.replyToId && (
|
{message.replyToId && (
|
||||||
// Subtle one-line reference to the replied-to message; clicking jumps
|
// Subtle one-line reference to the replied-to message; clicking jumps
|
||||||
// to it. Falls back to a muted note when the target was deleted.
|
// to it. Falls back to a muted note when the target was deleted.
|
||||||
@@ -192,7 +194,7 @@ function ChatMessageItem(
|
|||||||
|
|
||||||
{editing
|
{editing
|
||||||
? (
|
? (
|
||||||
<div className="chat-edit">
|
<div className="chat-edit animate-in">
|
||||||
<TextEditor
|
<TextEditor
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={editDraft}
|
value={editDraft}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { LikeButton } from "./LikeButton.tsx";
|
|||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
import { Tooltip } from "./Tooltip.tsx";
|
import { Tooltip } from "./Tooltip.tsx";
|
||||||
import { ConfirmModal } from "./ConfirmModal.tsx";
|
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||||
|
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import {
|
import {
|
||||||
expectOk,
|
expectOk,
|
||||||
@@ -51,6 +52,7 @@ interface CommentFormProps {
|
|||||||
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
/** `top` renders the avatar-flanked layout; `inline` is for reply/edit. */
|
||||||
layout?: "inline" | "top";
|
layout?: "inline" | "top";
|
||||||
leading?: React.ReactNode;
|
leading?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +71,7 @@ function CommentForm({
|
|||||||
editorRef,
|
editorRef,
|
||||||
layout = "inline",
|
layout = "inline",
|
||||||
leading,
|
leading,
|
||||||
|
className,
|
||||||
}: CommentFormProps) {
|
}: CommentFormProps) {
|
||||||
const form = useApiForm<{ body: string }>({
|
const form = useApiForm<{ body: string }>({
|
||||||
defaultValues: { body: defaultValue },
|
defaultValues: { body: defaultValue },
|
||||||
@@ -140,7 +143,10 @@ function CommentForm({
|
|||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<form className="comment-form" onSubmit={submit}>
|
<form
|
||||||
|
className={className ? `comment-form ${className}` : "comment-form"}
|
||||||
|
onSubmit={submit}
|
||||||
|
>
|
||||||
{inner}
|
{inner}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -194,6 +200,9 @@ function CommentNode({
|
|||||||
const [replyOpen, setReplyOpen] = useState(false);
|
const [replyOpen, setReplyOpen] = useState(false);
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const innerRef = useHeightTransition<HTMLDivElement>(
|
||||||
|
`${editOpen}:${replyOpen}`,
|
||||||
|
);
|
||||||
|
|
||||||
const replyEditorRef = useRef<TextEditorHandle>(null);
|
const replyEditorRef = useRef<TextEditorHandle>(null);
|
||||||
const editEditorRef = useRef<TextEditorHandle>(null);
|
const editEditorRef = useRef<TextEditorHandle>(null);
|
||||||
@@ -284,7 +293,11 @@ function CommentNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="comment-node">
|
<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">
|
<div className="comment-avatar">
|
||||||
<Avatar
|
<Avatar
|
||||||
userId={comment.userId}
|
userId={comment.userId}
|
||||||
@@ -328,6 +341,7 @@ function CommentNode({
|
|||||||
submitLabel={<Trans>Save</Trans>}
|
submitLabel={<Trans>Save</Trans>}
|
||||||
pendingLabel={<Trans>Saving…</Trans>}
|
pendingLabel={<Trans>Saving…</Trans>}
|
||||||
errorTitle={t`Failed to save edit`}
|
errorTitle={t`Failed to save edit`}
|
||||||
|
className="animate-in"
|
||||||
onCancel={() => setEditOpen(false)}
|
onCancel={() => setEditOpen(false)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -395,6 +409,7 @@ function CommentNode({
|
|||||||
submitLabel={<Trans>Post reply</Trans>}
|
submitLabel={<Trans>Post reply</Trans>}
|
||||||
pendingLabel={<Trans>Posting…</Trans>}
|
pendingLabel={<Trans>Posting…</Trans>}
|
||||||
errorTitle={t`Failed to post reply`}
|
errorTitle={t`Failed to post reply`}
|
||||||
|
className="animate-in"
|
||||||
placeholder={t`Write a reply…`}
|
placeholder={t`Write a reply…`}
|
||||||
onCancel={() => setReplyOpen(false)}
|
onCancel={() => setReplyOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface ImagePickerProps {
|
|||||||
onChange: (file: File) => void;
|
onChange: (file: File) => void;
|
||||||
uploading?: boolean;
|
uploading?: boolean;
|
||||||
accept?: string;
|
accept?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImagePicker({
|
export function ImagePicker({
|
||||||
@@ -18,6 +19,7 @@ export function ImagePicker({
|
|||||||
onChange,
|
onChange,
|
||||||
uploading = false,
|
uploading = false,
|
||||||
accept = "image/jpeg,image/png,image/gif,image/webp",
|
accept = "image/jpeg,image/png,image/gif,image/webp",
|
||||||
|
className,
|
||||||
}: ImagePickerProps) {
|
}: ImagePickerProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const sizeStyle = { width: size, height: size, borderRadius };
|
const sizeStyle = { width: size, height: size, borderRadius };
|
||||||
@@ -30,7 +32,7 @@ export function ImagePicker({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="img-picker"
|
className={className ? `img-picker ${className}` : "img-picker"}
|
||||||
style={sizeStyle}
|
style={sizeStyle}
|
||||||
onClick={() => !uploading && inputRef.current?.click()}
|
onClick={() => !uploading && inputRef.current?.click()}
|
||||||
title={src ? "Change image" : "Add image"}
|
title={src ? "Change image" : "Add image"}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface PlaylistCreateFormProps {
|
|||||||
dumpId?: string;
|
dumpId?: string;
|
||||||
onCreated: (playlist: Playlist) => void;
|
onCreated: (playlist: Playlist) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Values {
|
interface Values {
|
||||||
@@ -30,7 +31,7 @@ interface Values {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistCreateForm(
|
export function PlaylistCreateForm(
|
||||||
{ dumpId, onCreated, onCancel }: PlaylistCreateFormProps,
|
{ dumpId, onCreated, onCancel, className }: PlaylistCreateFormProps,
|
||||||
) {
|
) {
|
||||||
const { authFetch } = useAuth();
|
const { authFetch } = useAuth();
|
||||||
const form = useApiForm<Values>({
|
const form = useApiForm<Values>({
|
||||||
@@ -64,7 +65,10 @@ export function PlaylistCreateForm(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form className="form" onSubmit={onSubmit}>
|
<form
|
||||||
|
className={className ? `form ${className}` : "form"}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
<TextField<Values>
|
<TextField<Values>
|
||||||
name="title"
|
name="title"
|
||||||
placeholder={t`Title`}
|
placeholder={t`Title`}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function PlaylistMembershipPanel({
|
|||||||
{showNewForm
|
{showNewForm
|
||||||
? (
|
? (
|
||||||
<PlaylistCreateForm
|
<PlaylistCreateForm
|
||||||
|
className="animate-in"
|
||||||
dumpId={dumpId}
|
dumpId={dumpId}
|
||||||
onCreated={(playlist) => {
|
onCreated={(playlist) => {
|
||||||
onPlaylistCreated({ playlist, hasDump: true });
|
onPlaylistCreated({ playlist, hasDump: true });
|
||||||
|
|||||||
@@ -22,13 +22,25 @@ export function SearchBar(
|
|||||||
() => new URLSearchParams(location.search).get("q") ?? "",
|
() => 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) {
|
function setExpanded(v: boolean) {
|
||||||
if (!isControlled) setExpandedState(v);
|
if (!isControlled) setExpandedState(v);
|
||||||
onExpandedChange?.(v);
|
onExpandedChange?.(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [expanded, collapsible, isControlled]);
|
||||||
|
|
||||||
function handleIconClick() {
|
function handleIconClick() {
|
||||||
|
|||||||
68
src/hooks/useHeightTransition.ts
Normal file
68
src/hooks/useHeightTransition.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -65,3 +65,23 @@ button:hover {
|
|||||||
::selection {
|
::selection {
|
||||||
background: color-mix(in srgb, var(--color-accent) 30%, transparent);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { useAuth } from "../hooks/useAuth.ts";
|
|||||||
import { useCategories } from "../hooks/useCategories.ts";
|
import { useCategories } from "../hooks/useCategories.ts";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
|
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
import RichContentCard from "../components/RichContentCard.tsx";
|
import RichContentCard from "../components/RichContentCard.tsx";
|
||||||
@@ -74,6 +75,7 @@ export function Dump() {
|
|||||||
const [collections, setCollections] = useState<Playlist[]>([]);
|
const [collections, setCollections] = useState<Playlist[]>([]);
|
||||||
|
|
||||||
const [titleEditing, setTitleEditing] = useState(false);
|
const [titleEditing, setTitleEditing] = useState(false);
|
||||||
|
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
|
||||||
const [titleDraft, setTitleDraft] = useState("");
|
const [titleDraft, setTitleDraft] = useState("");
|
||||||
const [titleSaving, setTitleSaving] = useState(false);
|
const [titleSaving, setTitleSaving] = useState(false);
|
||||||
const [titleError, setTitleError] = useState<string | null>(null);
|
const [titleError, setTitleError] = useState<string | null>(null);
|
||||||
@@ -364,10 +366,10 @@ export function Dump() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dump-header-right">
|
<div className="dump-header-right" ref={headerRightRef}>
|
||||||
{titleEditing
|
{titleEditing
|
||||||
? (
|
? (
|
||||||
<div className="dump-title-editor">
|
<div className="dump-title-editor animate-in">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="dump-title-input"
|
className="dump-title-input"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { playlistUrl } from "../utils/urls.ts";
|
|||||||
import { can } from "../utils/permissions.ts";
|
import { can } from "../utils/permissions.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||||
|
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
import { DumpCard } from "../components/DumpCard.tsx";
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
@@ -105,6 +106,7 @@ export function PlaylistDetail() {
|
|||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
// Mirrors activeDumpIds for use in effects without adding it as a dep.
|
// Mirrors activeDumpIds for use in effects without adding it as a dep.
|
||||||
@@ -587,7 +589,7 @@ export function PlaylistDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="playlist-detail-header">
|
<div className="playlist-detail-header" ref={headerRef}>
|
||||||
<div className="playlist-detail-header-top">
|
<div className="playlist-detail-header-top">
|
||||||
{editOpen
|
{editOpen
|
||||||
? (
|
? (
|
||||||
@@ -842,6 +844,7 @@ function PlaylistEditForm(
|
|||||||
: null)}
|
: null)}
|
||||||
alt="Cover"
|
alt="Cover"
|
||||||
size={72}
|
size={72}
|
||||||
|
className="animate-in"
|
||||||
onChange={(file) => {
|
onChange={(file) => {
|
||||||
field.onChange(file);
|
field.onChange(file);
|
||||||
setImagePreview(URL.createObjectURL(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
|
<Controller
|
||||||
name="title"
|
name="title"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
|
||||||
|
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import {
|
import {
|
||||||
expectOk,
|
expectOk,
|
||||||
@@ -35,6 +36,7 @@ export function UserLogin() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [showReset, setShowReset] = useState(false);
|
const [showReset, setShowReset] = useState(false);
|
||||||
|
const cardRef = useHeightTransition<HTMLDivElement>(showReset);
|
||||||
|
|
||||||
const form = useApiForm<LoginValues>({
|
const form = useApiForm<LoginValues>({
|
||||||
defaultValues: { username: "", password: "" },
|
defaultValues: { username: "", password: "" },
|
||||||
@@ -54,7 +56,7 @@ export function UserLogin() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell centered>
|
<PageShell centered>
|
||||||
<div className="auth-card">
|
<div className="auth-card" ref={cardRef}>
|
||||||
<h1 className="auth-card-title">
|
<h1 className="auth-card-title">
|
||||||
<Trans>Log in</Trans>
|
<Trans>Log in</Trans>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -115,7 +117,7 @@ function ResetRequestPanel() {
|
|||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-reset-panel">
|
<div className="auth-reset-panel animate-in">
|
||||||
<p className="auth-reset-sent">
|
<p className="auth-reset-sent">
|
||||||
<Trans>
|
<Trans>
|
||||||
If that address is registered you'll receive a reset link shortly.
|
If that address is registered you'll receive a reset link shortly.
|
||||||
@@ -126,7 +128,7 @@ function ResetRequestPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-reset-panel">
|
<div className="auth-reset-panel animate-in">
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={onSubmit} className="form auth-reset-form">
|
<form onSubmit={onSubmit} className="form auth-reset-form">
|
||||||
<FormError title={t`Request failed`} />
|
<FormError title={t`Request failed`} />
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { useDefaultFeedTab } from "../hooks/useDefaultFeedTab.ts";
|
|||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
import { useFading } from "../hooks/useFading.ts";
|
import { useFading } from "../hooks/useFading.ts";
|
||||||
|
import { useHeightTransition } from "../hooks/useHeightTransition.ts";
|
||||||
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||||
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||||
@@ -327,6 +328,8 @@ export function UserPublicProfile() {
|
|||||||
|
|
||||||
const [descEditing, setDescEditing] = useState(false);
|
const [descEditing, setDescEditing] = useState(false);
|
||||||
const [emailEditing, setEmailEditing] = 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 prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||||
@@ -786,7 +789,7 @@ export function UserPublicProfile() {
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="profile-info">
|
<div className="profile-info" ref={infoRef}>
|
||||||
<div className="profile-info-scroll">
|
<div className="profile-info-scroll">
|
||||||
<div className="profile-username-row">
|
<div className="profile-username-row">
|
||||||
<h1 className="profile-username">{profileUser.username}</h1>
|
<h1 className="profile-username">{profileUser.username}</h1>
|
||||||
@@ -856,7 +859,7 @@ export function UserPublicProfile() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(profileUser.description || isOwnProfile) && (
|
{(profileUser.description || isOwnProfile) && (
|
||||||
<div className="profile-description">
|
<div className="profile-description" ref={descRef}>
|
||||||
{descEditing
|
{descEditing
|
||||||
? (
|
? (
|
||||||
<DescriptionEditor
|
<DescriptionEditor
|
||||||
@@ -1646,7 +1649,7 @@ function EmailEditor({ initialEmail, onCancel, onSaved }: EmailEditorProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form className="profile-email-editor" onSubmit={onSubmit}>
|
<form className="profile-email-editor animate-in" onSubmit={onSubmit}>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
className="profile-email-input"
|
className="profile-email-input"
|
||||||
@@ -1712,7 +1715,10 @@ function DescriptionEditor(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form className="profile-description-editor" onSubmit={onSubmit}>
|
<form
|
||||||
|
className="profile-description-editor animate-in"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
<Controller
|
<Controller
|
||||||
name="description"
|
name="description"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@
|
|||||||
--dur-fast: 0.12s;
|
--dur-fast: 0.12s;
|
||||||
--dur-base: 0.15s;
|
--dur-base: 0.15s;
|
||||||
--dur-slow: 0.2s;
|
--dur-slow: 0.2s;
|
||||||
|
--dur-reveal: 0.25s;
|
||||||
|
|
||||||
/* ── Layers ── */
|
/* ── Layers ── */
|
||||||
--z-raised: 1;
|
--z-raised: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user