v3: localization fixes, char counters & limits on all text fields, ux fixes

This commit is contained in:
khannurien
2026-04-03 19:47:37 +00:00
parent 0ce80398a4
commit a69788c15b
48 changed files with 1133 additions and 305 deletions

View File

@@ -6,10 +6,10 @@ import React, {
useState,
} from "react";
import { Link, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { API_URL, DEFAULT_PAGE_SIZE, VALIDATION } from "../config/api.ts";
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
import {
deserializeAuthResponse,
@@ -89,7 +89,9 @@ function InviteButton() {
<button type="button" className="invite-btn" onClick={generate}>
<Trans>+ Invite someone</Trans>
</button>
{error && <ErrorCard title={t`Failed to generate invite`} message={error} />}
{error && (
<ErrorCard title={t`Failed to generate invite`} message={error} />
)}
</div>
);
}
@@ -550,7 +552,10 @@ export function UserPublicProfile() {
};
const handleDescSave = async () => {
if (state.status !== "loaded") return;
if (
state.status !== "loaded" ||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX
) return;
setDescSaving(true);
setDescError(null);
try {
@@ -587,7 +592,9 @@ export function UserPublicProfile() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading profile</Trans></p>
<p className="page-loading">
<Trans>Loading profile</Trans>
</p>
</PageShell>
);
}
@@ -689,7 +696,9 @@ export function UserPublicProfile() {
className="profile-email-btn profile-email-btn--save"
disabled={emailSaving || !emailDraft.trim()}
>
{emailSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
{emailSaving
? <Trans>Saving</Trans>
: <Trans>Save</Trans>}
</button>
<button
type="button"
@@ -723,7 +732,10 @@ export function UserPublicProfile() {
)
)}
{avatarError && (
<ErrorCard title={t`Failed to update avatar`} message={avatarError} />
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
@@ -754,13 +766,15 @@ export function UserPublicProfile() {
onSubmit={handleDescSave}
placeholder={t`Tell people about yourself…`}
autoResize
maxLength={VALIDATION.USER_DESCRIPTION_MAX}
/>
<div className="profile-description-actions">
<button
type="button"
className="btn-primary"
onClick={handleDescSave}
disabled={descSaving}
disabled={descSaving ||
descDraft.length > VALIDATION.USER_DESCRIPTION_MAX}
>
{descSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
@@ -842,7 +856,10 @@ export function UserPublicProfile() {
<section className="profile-section" id="playlists">
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})</Trans>
<Trans>
Playlists ({playlists.items.length}
{playlists.hasMore ? "+" : ""})
</Trans>
</h2>
{isOwnProfile && (
<NewPlaylistForm
@@ -862,7 +879,11 @@ export function UserPublicProfile() {
)}
</div>
{playlists.items.length === 0
? <p className="empty-state"><Trans>No playlists yet.</Trans></p>
? (
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{playlists.items.map((p) => (
@@ -927,7 +948,11 @@ function DumpList(
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}
{dumps.length === 0
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{dumps.map((dump) => (
@@ -945,7 +970,9 @@ function DumpList(
</ul>
)}
{dumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all"><Trans>View all </Trans></Link>
<Link to={viewAllHref} className="profile-view-all">
<Trans>View all </Trans>
</Link>
)}
</section>
);
@@ -998,9 +1025,7 @@ function UpvotedDumpList(
useEffect(() => {
if (!profileUserId || !isOwnProfile) return;
if (prevMyVotesRef.current === null) {
// setVotedIds must fire here alongside prevMyVotesRef mutation; render-phase
// isn't possible because startFading/cancelFading (below) are also setState
// calls that cannot be invoked during render.
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(wsMyVotes));
prevMyVotesRef.current = new Set(wsMyVotes);
@@ -1021,8 +1046,8 @@ function UpvotedDumpList(
const { dumpId, voterId, action } = lastVoteEvent;
if (voterId !== profileUserId) return;
if (action === "remove") {
// setVotedIds + startFading must be coordinated in the same effect body
// to guarantee a single render — render-phase can't call startFading (setState).
// setVotedIds and startFading must fire together to avoid a render with
// stale votedIds between the two updates.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds((prev) => {
const n = new Set(prev);
@@ -1046,7 +1071,11 @@ function UpvotedDumpList(
<h2 className="profile-section-title">{title}</h2>
</div>
{visibleDumps.length === 0
? <p className="empty-state"><Trans>Nothing here yet.</Trans></p>
? (
<p className="empty-state">
<Trans>Nothing here yet.</Trans>
</p>
)
: (
<ul className="dump-feed">
{visibleDumps.map((dump) => {
@@ -1073,7 +1102,9 @@ function UpvotedDumpList(
</ul>
)}
{visibleDumps.length > 0 && (
<Link to={viewAllHref} className="profile-view-all"><Trans>View all </Trans></Link>
<Link to={viewAllHref} className="profile-view-all">
<Trans>View all </Trans>
</Link>
)}
</section>
);