Compare commits
2 Commits
decea1c614
...
8c3efe6c30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c3efe6c30 | ||
|
|
5e5f984d87 |
45
src/App.css
45
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 {
|
||||
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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
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 {
|
||||
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 { 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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
--dur-fast: 0.12s;
|
||||
--dur-base: 0.15s;
|
||||
--dur-slow: 0.2s;
|
||||
--dur-reveal: 0.25s;
|
||||
|
||||
/* ── Layers ── */
|
||||
--z-raised: 1;
|
||||
|
||||
Reference in New Issue
Block a user