v4: a playlist's edit form is a .form like the dump's, and its dumps keep the header's edges — the editor laid the cover beside its fields with display: contents, which on a phone left them a squeezed column, and drew its own inputs: the title and the slug both in bold large type, the description in another size, only the slug labelled, and a segmented control styled apart. It now stacks the shared fields, each under its label (Cover, Title with its counter, Link, Description, the visibility toggle), over the action row, as DumpEdit does, and takes the header's whole card. The dumps' drag handle and remove button took width from the cards, which sat inset from the header on both sides; they now sit in the page's side padding (--page-gutter, which .page-content now names), so the cards line up with the header at any width. Undo shows as a small btn-primary over the card it would bring back, instead of a third control beside it. CountedInput, VisibilitySegmented and the .playlist-edit-* rules had no other users and are gone. 669 frontend tests pass, and tsc, eslint, deno fmt, doc:lint and i18n:compile --strict are clean; checked in screenshots in all six styles at 390px, Kanagawa and Smooth at desktop widths too, Undo in Smooth and GeoCities.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
121
src/App.css
121
src/App.css
@@ -265,9 +265,7 @@
|
||||
Each rule below adds only its own size / weight / width deltas. */
|
||||
.dump-title-input,
|
||||
.profile-email-input,
|
||||
.username-field-input,
|
||||
.playlist-edit-input,
|
||||
.playlist-edit-textarea {
|
||||
.username-field-input {
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-sunken);
|
||||
@@ -279,9 +277,7 @@
|
||||
|
||||
.dump-title-input:focus,
|
||||
.profile-email-input:focus,
|
||||
.username-field-input:focus,
|
||||
.playlist-edit-input:focus,
|
||||
.playlist-edit-textarea:focus {
|
||||
.username-field-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -2711,7 +2707,8 @@ body.has-player .chat-fab {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem 0;
|
||||
--page-gutter: 1.25rem;
|
||||
padding: 2rem var(--page-gutter) 0;
|
||||
box-sizing: border-box;
|
||||
animation: page-enter 0.2s ease both;
|
||||
display: flex;
|
||||
@@ -5308,11 +5305,6 @@ body.has-player .chat-fab {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.playlist-detail-title-row .playlist-edit-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-detail-title {
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
@@ -5370,41 +5362,12 @@ body.has-player .chat-fab {
|
||||
var(--dur-base);
|
||||
}
|
||||
|
||||
.playlist-edit-btn:hover,
|
||||
.playlist-cancel-btn:hover {
|
||||
.playlist-edit-btn:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.playlist-edit-input,
|
||||
.playlist-edit-textarea {
|
||||
width: 100%;
|
||||
font-size: var(--text-md);
|
||||
padding: 0.4rem 0.65rem;
|
||||
}
|
||||
|
||||
.playlist-edit-textarea {
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.playlist-edit-input {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Inline edit form: stack the fields with a little more breathing room than
|
||||
the tight display layout, and let the action row sit at the bottom. */
|
||||
.playlist-edit-content {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.playlist-detail-meta .playlist-edit-toggle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Playlist dump list ── */
|
||||
.playlist-dump-list {
|
||||
display: flex;
|
||||
@@ -5413,31 +5376,55 @@ body.has-player .chat-fab {
|
||||
}
|
||||
|
||||
.playlist-dump-item {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
transition: background var(--dur-base);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* For those who may edit the playlist, each card has a drag handle on its
|
||||
left and a remove button on its right. They sit in the page's side
|
||||
padding, so the cards keep the header's edges. Undo shows over the card it
|
||||
would bring back, dimmed during its cooldown. */
|
||||
.playlist-dump-list--editable .playlist-dump-item {
|
||||
grid-template-columns: var(--page-gutter) minmax(0, 1fr) var(--page-gutter);
|
||||
margin-inline: calc(-1 * var(--page-gutter));
|
||||
}
|
||||
|
||||
.playlist-dump-item .dump-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-dump-list--editable .playlist-dump-item .dump-card {
|
||||
grid-area: 1 / 2;
|
||||
}
|
||||
|
||||
.playlist-dump-item--drag-over {
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
outline: 2px dashed var(--color-accent);
|
||||
}
|
||||
|
||||
.playlist-dump-item .dump-card {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.drag-handle {
|
||||
grid-column: 1;
|
||||
justify-self: center;
|
||||
cursor: grab;
|
||||
opacity: 0.35;
|
||||
font-size: var(--text-lg);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ── Playlist dump item action buttons ── */
|
||||
.playlist-remove-btn {
|
||||
flex-shrink: 0;
|
||||
grid-column: 3;
|
||||
align-self: stretch;
|
||||
padding: 0;
|
||||
color: var(--color-text);
|
||||
opacity: 0.4;
|
||||
font-size: var(--text-sm);
|
||||
padding: 0.25rem 0.35rem;
|
||||
}
|
||||
|
||||
.playlist-remove-btn:hover {
|
||||
@@ -5445,32 +5432,12 @@ body.has-player .chat-fab {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.playlist-cancel-btn {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
opacity: 0.7;
|
||||
font-size: var(--text-xs);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: opacity var(--dur-base), border-color var(--dur-base), color
|
||||
var(--dur-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.35;
|
||||
font-size: var(--text-lg);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
.playlist-undo-btn {
|
||||
grid-area: 1 / 2;
|
||||
justify-self: center;
|
||||
/* Over the card, which is positioned. */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Add to playlist button (dump detail) ── */
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
*/
|
||||
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
Segmented,
|
||||
type SegmentedOption,
|
||||
type SegmentedProps,
|
||||
} from "../shared/Segmented.tsx";
|
||||
import { Segmented, type SegmentedOption } from "../shared/Segmented.tsx";
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
@@ -92,14 +88,3 @@ export function VisibilityToggle<T extends FieldValues>({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a Public/Private {@link Segmented} outside `SegmentedField`, for
|
||||
* `PlaylistEditForm`, which binds it with its own `Controller` and styles it
|
||||
* apart.
|
||||
*/
|
||||
export function VisibilitySegmented(
|
||||
props: Omit<SegmentedProps<boolean>, "options">,
|
||||
): React.JSX.Element {
|
||||
return <Segmented options={VISIBILITY_OPTIONS} {...props} />;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,9 @@ export function PlaylistDumpList(
|
||||
let activeIndex = 0;
|
||||
return (
|
||||
<div
|
||||
className="playlist-dump-list"
|
||||
className={`playlist-dump-list${
|
||||
canDrag ? " playlist-dump-list--editable" : ""
|
||||
}`}
|
||||
onDragOver={canDrag ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
{dumps.map((dump) => {
|
||||
@@ -113,7 +115,7 @@ export function PlaylistDumpList(
|
||||
{phase === "cooldown" && onUndoRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-cancel-btn"
|
||||
className="btn-primary btn-sm playlist-undo-btn"
|
||||
onClick={() => onUndoRemove(dump.id)}
|
||||
aria-label={t`Cancel removal`}
|
||||
>
|
||||
|
||||
@@ -22,10 +22,10 @@ import { playlistImageUrl } from "../../utils/shared/urls.ts";
|
||||
import { expectOk } from "../../utils/shared/api.ts";
|
||||
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
||||
import { useApiForm } from "../../hooks/form/useApiForm.ts";
|
||||
import { CountedInput } from "../shared/CountedInput.tsx";
|
||||
import { ImagePicker } from "../shared/ImagePicker.tsx";
|
||||
import { TextEditor } from "../editor/TextEditor.tsx";
|
||||
import { VisibilitySegmented } from "../form/SegmentedField.tsx";
|
||||
import { TextField } from "../form/TextField.tsx";
|
||||
import { RichTextField } from "../form/RichTextField.tsx";
|
||||
import { VisibilityToggle } from "../form/SegmentedField.tsx";
|
||||
import { FormError } from "../form/FormError.tsx";
|
||||
import { SubmitButton } from "../form/SubmitButton.tsx";
|
||||
import { SlugField } from "../form/SlugField.tsx";
|
||||
@@ -75,7 +75,6 @@ export function PlaylistEditForm(
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
const description = form.watch("description");
|
||||
const title = form.watch("title");
|
||||
|
||||
const onSubmit = form.submit(
|
||||
@@ -121,110 +120,85 @@ export function PlaylistEditForm(
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{
|
||||
/* `display: contents` keeps the header's layout, the cover beside the
|
||||
fields, around a real <form>, which Enter submits. */
|
||||
}
|
||||
<form style={{ display: "contents" }} onSubmit={onSubmit}>
|
||||
<Controller
|
||||
name="image"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? playlistImageUrl(playlist, mediaToken)
|
||||
: null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
className="animate-in"
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<form
|
||||
className="form animate-in"
|
||||
onSubmit={onSubmit}
|
||||
onKeyDown={(e) => {
|
||||
// The editor's popups take their own Escape and prevent it.
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onCancel();
|
||||
}}
|
||||
>
|
||||
<div className="form-field">
|
||||
<span className="form-label">
|
||||
<Trans>Cover</Trans>
|
||||
</span>
|
||||
<Controller
|
||||
name="image"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ImagePicker
|
||||
src={imagePreview ??
|
||||
(playlist.imageMime
|
||||
? playlistImageUrl(playlist, mediaToken)
|
||||
: null)}
|
||||
alt="Cover"
|
||||
size={72}
|
||||
onChange={(file) => {
|
||||
field.onChange(file);
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextField<EditValues>
|
||||
name="title"
|
||||
label={t`Title`}
|
||||
autoFocus
|
||||
rules={{ required: true }}
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
showCount
|
||||
/>
|
||||
|
||||
<div className="playlist-detail-content playlist-edit-content animate-in">
|
||||
<Controller
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CountedInput
|
||||
className="playlist-edit-input"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder={t`Playlist title`}
|
||||
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SlugField<EditValues>
|
||||
name="slug"
|
||||
titleName="title"
|
||||
prefix={`${userPath(playlist.ownerUsername, "playlists")}/`}
|
||||
saved={playlist}
|
||||
/>
|
||||
|
||||
<SlugField<EditValues>
|
||||
name="slug"
|
||||
titleName="title"
|
||||
prefix={`${userPath(playlist.ownerUsername, "playlists")}/`}
|
||||
saved={playlist}
|
||||
inputClassName="playlist-edit-input"
|
||||
/>
|
||||
<RichTextField<EditValues>
|
||||
name="description"
|
||||
label={t`Description`}
|
||||
rows={2}
|
||||
autoResize
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
onSubmit={() => void onSubmit()}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextEditor
|
||||
className="playlist-edit-textarea"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onSubmit={() => void onSubmit()}
|
||||
onCancel={onCancel}
|
||||
placeholder={t`Description (optional)`}
|
||||
autoResize
|
||||
rows={2}
|
||||
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<VisibilityToggle<EditValues> name="isPublic" />
|
||||
|
||||
<Controller
|
||||
name="isPublic"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<VisibilitySegmented
|
||||
className="playlist-edit-toggle"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FormError title={t`Failed to save`} />
|
||||
|
||||
<FormError title={t`Failed to save`} />
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<button type="button" className="form-cancel" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<button type="button" className="form-cancel" onClick={onCancel}>
|
||||
<Trans>Cancel</Trans>
|
||||
</button>
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={!title.trim() ||
|
||||
description.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<SubmitButton
|
||||
pendingLabel={<Trans>Saving…</Trans>}
|
||||
disabled={!title.trim()}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Renders a controlled text input with a character counter. Unlike `TextField`,
|
||||
* it takes any input prop, for an inline editor such as a playlist's title.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { charCountClass } from "../../utils/shared/charCount.ts";
|
||||
|
||||
/**
|
||||
* Passed to {@link CountedInput} by its caller, which holds the value. The rest
|
||||
* goes to the `<input>`.
|
||||
*/
|
||||
export interface CountedInputProps
|
||||
extends
|
||||
Omit<React.InputHTMLAttributes<HTMLInputElement>, "maxLength" | "value"> {
|
||||
value: string;
|
||||
/** Stops the typing at this length, and is the counter's limit. */
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the input and an `n / maxLength` counter, which changes colour as the
|
||||
* value nears its limit (`charCountClass`).
|
||||
*/
|
||||
export function CountedInput(
|
||||
{ value, maxLength, ...rest }: CountedInputProps,
|
||||
): React.JSX.Element {
|
||||
const countClass = charCountClass(value.length, maxLength);
|
||||
|
||||
return (
|
||||
<div className="input-with-count">
|
||||
<input value={value} maxLength={maxLength} {...rest} />
|
||||
<span className={`text-editor-count${countClass}`}>
|
||||
{value.length} / {maxLength}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,28 +91,28 @@ export function PlaylistDetail(): React.JSX.Element {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="playlist-detail-header" ref={headerRef}>
|
||||
<div className="playlist-detail-header-top">
|
||||
{editOpen
|
||||
? (
|
||||
<PlaylistEditForm
|
||||
playlist={playlist}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
onSaved={(updated) => {
|
||||
setEditOpen(false);
|
||||
// Shown at once rather than left to the socket's echo,
|
||||
// which may never come (disconnected).
|
||||
applyDetails(updated);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
{editOpen
|
||||
? (
|
||||
<PlaylistEditForm
|
||||
playlist={playlist}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onRequestDelete={() => setConfirmDelete(true)}
|
||||
onSaved={(updated) => {
|
||||
setEditOpen(false);
|
||||
// Shown at once rather than left to the socket's echo,
|
||||
// which may never come (disconnected).
|
||||
applyDetails(updated);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="playlist-detail-header-top">
|
||||
<PlaylistHeader
|
||||
playlist={playlist}
|
||||
onEdit={canEdit ? () => setEditOpen(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { CountedInput } from "../../../components/shared/CountedInput.tsx";
|
||||
import { renderWithContexts } from "../../render.tsx";
|
||||
|
||||
describe("CountedInput", () => {
|
||||
test("CountedInput counts toward its limit", () => {
|
||||
renderWithContexts(
|
||||
<CountedInput
|
||||
value={"x".repeat(90)}
|
||||
maxLength={100}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
const count = screen.getByText("90 / 100");
|
||||
expect(count).toHaveClass("text-editor-count--warn");
|
||||
expect(screen.getByRole("textbox")).toHaveAttribute("maxlength", "100");
|
||||
});
|
||||
});
|
||||
@@ -130,7 +130,7 @@ describe("PlaylistDetail", () => {
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const box = screen.getByPlaceholderText("Description (optional)");
|
||||
const box = screen.getByRole("textbox", { name: "Description" });
|
||||
await user.clear(box);
|
||||
await user.type(box, "Better tunes");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
@@ -157,7 +157,7 @@ describe("PlaylistDetail", () => {
|
||||
);
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
const title = screen.getByPlaceholderText("Playlist title");
|
||||
const title = screen.getByRole("textbox", { name: "Title" });
|
||||
await user.clear(title);
|
||||
await user.type(title, "Re-mix!");
|
||||
const link = screen.getByRole("textbox", { name: "Link" });
|
||||
@@ -239,7 +239,7 @@ describe("PlaylistDetail", () => {
|
||||
test("Escape leaves the editor", async () => {
|
||||
const { user } = open();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit" }));
|
||||
await user.type(screen.getByPlaceholderText("Playlist title"), "{Escape}");
|
||||
await user.type(screen.getByRole("textbox", { name: "Title" }), "{Escape}");
|
||||
expect(screen.getByRole("heading", { name: "Mix" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user