The GeoCities marquee stops only for a link the keyboard focused: a link pressed with the pointer took the focus as well, which put the line back at its start and scrolled it, so the link slipped from under the pointer before the button came up and the click was lost. The next page's marquee also carries on from the very point the last one had reached, with the loop it showed: it worked its place out from when the loop began, which ran on while the crawl was paused under the pointer, and showed the feed as it was by then, of another width. The chat's message field rests on one line, as tall as the Send button: it rested on two, 54 to 62 pixels against the button's 38, and the button sat low beside it. Both take --chat-composer-height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
5.6 KiB
TypeScript
169 lines
5.6 KiB
TypeScript
/**
|
|
* Renders the header of a playlist's page, with its cover, title, description,
|
|
* visibility, owner, dates and categories.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { Link } from "react-router";
|
|
import { t } from "@lingui/core/macro";
|
|
import { Trans } from "@lingui/react/macro";
|
|
import { API_URL } from "../../config/api.ts";
|
|
import { VALIDATION } from "../../../shared/validation.ts";
|
|
import type { Wire } from "../../../shared/api.ts";
|
|
import type {
|
|
Playlist,
|
|
PlaylistWithDumps,
|
|
UpdatePlaylistRequest,
|
|
} from "../../../shared/playlist.ts";
|
|
import { expectOk } from "../../utils/shared/api.ts";
|
|
import { deserializePlaylist } from "../../utils/shared/deserialize.ts";
|
|
import { imageProps, playlistImageUrl } from "../../utils/shared/urls.ts";
|
|
import { IMAGE_SIZES } from "../../config/imageSizes.ts";
|
|
import { relativeTime } from "../../utils/shared/relativeTime.ts";
|
|
import { useAuth } from "../../hooks/auth/useAuth.ts";
|
|
import { Markdown } from "../editor/Markdown.tsx";
|
|
import { InlineEdit } from "../form/InlineEdit.tsx";
|
|
import { FollowPlaylistButton } from "../social/FollowButton.tsx";
|
|
import { Tooltip } from "../shared/Tooltip.tsx";
|
|
import { CategoryChips } from "../category/CategoryChips.tsx";
|
|
import { userPath } from "../../../shared/paths.ts";
|
|
|
|
/** Passed to {@link PlaylistHeader} by `PlaylistDetail`. */
|
|
export interface PlaylistHeaderProps {
|
|
playlist: PlaylistWithDumps;
|
|
/**
|
|
* Given to those who may edit it, with `onSaved`: it adds an "Edit" button,
|
|
* which opens `PlaylistEditForm`.
|
|
*/
|
|
onEdit?: () => void;
|
|
/**
|
|
* Called with the playlist as the server has it once its title or
|
|
* description is edited in place, which it lets its editors do.
|
|
*/
|
|
onSaved?: (playlist: Playlist) => void;
|
|
}
|
|
|
|
/**
|
|
* Renders the playlist's details, with a follow button for anyone but its
|
|
* owner. Its editors change the title and the description in place, saved to
|
|
* `PUT /api/playlists/:playlistId`, and the rest in `PlaylistEditForm`, which
|
|
* takes the header's place while it is open.
|
|
*/
|
|
export function PlaylistHeader(
|
|
{ playlist, onEdit, onSaved }: PlaylistHeaderProps,
|
|
): React.JSX.Element {
|
|
const { user, mediaToken, authFetch } = useAuth();
|
|
const save = async (changes: UpdatePlaylistRequest) => {
|
|
const updated = await expectOk<Wire<Playlist>>(
|
|
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(changes),
|
|
}),
|
|
);
|
|
onSaved?.(deserializePlaylist(updated));
|
|
};
|
|
return (
|
|
<>
|
|
{playlist.imageMime && (
|
|
<img
|
|
{...imageProps(
|
|
playlistImageUrl(playlist, mediaToken),
|
|
IMAGE_SIZES.playlistBanner,
|
|
)}
|
|
alt=""
|
|
className="playlist-detail-img"
|
|
/>
|
|
)}
|
|
<div className="playlist-detail-content">
|
|
<div className="playlist-detail-title-row">
|
|
<InlineEdit
|
|
value={playlist.title}
|
|
editable={!!onSaved}
|
|
label={t`Edit title`}
|
|
onSave={(title) => save({ title })}
|
|
required
|
|
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
|
|
fieldClassName="playlist-detail-title"
|
|
>
|
|
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
|
</InlineEdit>
|
|
{user?.id !== playlist.userId && (
|
|
<FollowPlaylistButton
|
|
targetPlaylistId={playlist.id}
|
|
isPublic={playlist.isPublic}
|
|
/>
|
|
)}
|
|
{onEdit && (
|
|
<button
|
|
type="button"
|
|
className="playlist-edit-btn"
|
|
onClick={onEdit}
|
|
>
|
|
<Trans>Edit</Trans>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{(playlist.description || onSaved) && (
|
|
<InlineEdit
|
|
value={playlist.description ?? ""}
|
|
editable={!!onSaved}
|
|
label={t`Edit description`}
|
|
onSave={(description) => save({ description: description || null })}
|
|
kind="markdown"
|
|
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
|
|
placeholder={t`Add a description…`}
|
|
|
|
>
|
|
{playlist.description
|
|
? (
|
|
<Markdown className="playlist-detail-description">
|
|
{playlist.description}
|
|
</Markdown>
|
|
)
|
|
: (
|
|
<p className="inline-edit-empty">
|
|
<Trans>Add a description…</Trans>
|
|
</p>
|
|
)}
|
|
</InlineEdit>
|
|
)}
|
|
|
|
<div className="playlist-detail-meta">
|
|
<span
|
|
className={`playlist-badge${
|
|
playlist.isPublic ? "" : " playlist-badge--private"
|
|
}`}
|
|
>
|
|
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
|
|
</span>
|
|
{playlist.ownerUsername && (
|
|
<Link
|
|
to={userPath(playlist.ownerUsername)}
|
|
className="playlist-detail-owner"
|
|
>
|
|
@{playlist.ownerUsername}
|
|
</Link>
|
|
)}
|
|
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
|
<time dateTime={playlist.createdAt.toISOString()}>
|
|
{relativeTime(playlist.createdAt)}
|
|
</time>
|
|
</Tooltip>
|
|
{playlist.updatedAt && (
|
|
<Tooltip text={t`Edited ${playlist.updatedAt.toLocaleString()}`}>
|
|
<span className="playlist-edited-label">
|
|
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans>
|
|
</span>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
|
|
<CategoryChips kind="playlist" ids={playlist.categoryIds} />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|