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

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { dumpUrl } from "../utils/urls.ts";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
@@ -190,7 +190,9 @@ export function Dump() {
if (dumpState.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading dump</Trans></p>
<p className="page-loading">
<Trans>Loading dump</Trans>
</p>
</PageShell>
);
}
@@ -315,7 +317,9 @@ export function Dump() {
<Trans>Edit</Trans>
</Link>
)}
<Link to="/"><Trans> Back to all dumps</Trans></Link>
<Link to="/">
<Trans> Back to all dumps</Trans>
</Link>
</div>
{/* Comments */}

View File

@@ -1,9 +1,9 @@
import { useEffect, 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 } from "../config/api.ts";
import { API_URL, VALIDATION } from "../config/api.ts";
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
@@ -65,7 +65,9 @@ export function DumpEdit() {
}, [selectedDump, token]);
const handleSave = async () => {
if (state.status !== "loaded") return;
if (
state.status !== "loaded" || comment.length > VALIDATION.DUMP_COMMENT_MAX
) return;
let res: Response;
@@ -140,7 +142,9 @@ export function DumpEdit() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading dump</Trans></p>
<p className="page-loading">
<Trans>Loading dump</Trans>
</p>
</PageShell>
);
}
@@ -177,7 +181,9 @@ export function DumpEdit() {
<PageShell>
<div className="form-page form-page--two-col">
<div className="form-page-header">
<p className="form-page-eyebrow"><Trans>Editing</Trans></p>
<p className="form-page-eyebrow">
<Trans>Editing</Trans>
</p>
<h1 className="form-page-title">{dump.title}</h1>
</div>
@@ -203,7 +209,9 @@ export function DumpEdit() {
onClick={handleRefreshMetadata}
disabled={refreshing}
>
{refreshing ? <Trans>Refreshing</Trans> : <Trans>Refresh metadata</Trans>}
{refreshing
? <Trans>Refreshing</Trans>
: <Trans>Refresh metadata</Trans>}
</button>
)}
</div>
@@ -218,7 +226,9 @@ export function DumpEdit() {
{dump.kind === "url"
? (
<div className="form-group">
<label htmlFor="url"><Trans>URL</Trans></label>
<label htmlFor="url">
<Trans>URL</Trans>
</label>
<input
id="url"
type="url"
@@ -255,6 +265,7 @@ export function DumpEdit() {
onChange={setComment}
placeholder={t`Tell the community what makes this worth their time...`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
/>
</div>
@@ -287,7 +298,11 @@ export function DumpEdit() {
<Link to={dumpUrl(dump)} className="form-cancel">
<Trans>Cancel</Trans>
</Link>
<button type="submit" className="btn-primary">
<button
type="submit"
className="btn-primary"
disabled={comment.length > VALIDATION.DUMP_COMMENT_MAX}
>
<Trans>Save</Trans>
</button>
</div>

View File

@@ -83,9 +83,21 @@ export function Index() {
);
const mainFetchDone = useRef(false);
const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot";
const searchParams = new URLSearchParams(location.search);
const rawTab = searchParams.get("tab") ?? "hot";
const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot";
// Web Share Target: Android share sheet navigates to /?share_url=...
const shareUrl = searchParams.get("share_url") ??
searchParams.get("share_text") ?? "";
useEffect(() => {
if (!shareUrl) return;
// Clean share params from the URL so a refresh doesn't re-open the modal
const clean = tab !== "hot" ? `?tab=${tab}` : "";
globalThis.history.replaceState({}, "", location.pathname + clean);
}, [shareUrl, tab, location.pathname]);
// ── Main feed fetch ──
useEffect(() => {
@@ -241,6 +253,7 @@ export function Index() {
</div>
}
disableNew={dumpsState.status === "error"}
initialDumpUrl={shareUrl || undefined}
/>
<div className="index-below-header">

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
@@ -315,7 +315,9 @@ export function Notifications() {
</div>
{state.status === "loading" && (
<p className="page-loading"><Trans>Loading</Trans></p>
<p className="page-loading">
<Trans>Loading</Trans>
</p>
)}
{state.status === "error" && (
<ErrorCard title={t`Failed to load`} message={state.error} />
@@ -324,7 +326,9 @@ export function Notifications() {
{state.status === "loaded" && state.items.length === 0 && (
<div className="notifications-empty">
<span className="notifications-empty-icon">🔕</span>
<p><Trans>Nothing here yet.</Trans></p>
<p>
<Trans>Nothing here yet.</Trans>
</p>
<p className="notifications-empty-hint">
<Trans>
You'll be notified when someone follows your playlists, upvotes
@@ -338,7 +342,11 @@ export function Notifications() {
groupByDate(state.items).map(({ label, items }) => (
<section key={label} className="notif-group">
<h2 className="notif-group-label">
{label === "Today" ? t`Today` : label === "Yesterday" ? t`Yesterday` : t`Earlier`}
{label === "Today"
? t`Today`
: label === "Yesterday"
? t`Yesterday`
: t`Earlier`}
</h2>
<ul className="notification-list">
{items.map((n) => (
@@ -383,7 +391,9 @@ export function Notifications() {
onClick={loadMore}
disabled={state.loadingMore}
>
{state.loadingMore ? <Trans>Loading</Trans> : <Trans>Load more</Trans>}
{state.loadingMore
? <Trans>Loading</Trans>
: <Trans>Load more</Trans>}
</button>
)}
</div>

View File

@@ -6,9 +6,10 @@ import {
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 } from "../config/api.ts";
import { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "../components/CountedInput.tsx";
import type {
PlaylistWithDumps,
RawDump,
@@ -527,7 +528,10 @@ export function PlaylistDetail() {
};
const handleEditSave = async () => {
if (!playlistId || state.status !== "loaded") return;
if (
!playlistId || state.status !== "loaded" ||
editDescription.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return;
setEditSaving(true);
setEditError(null);
try {
@@ -587,7 +591,9 @@ export function PlaylistDetail() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading playlist</Trans></p>
<p className="page-loading">
<Trans>Loading playlist</Trans>
</p>
</PageShell>
);
}
@@ -649,17 +655,19 @@ export function PlaylistDetail() {
{editOpen
? (
<div className="playlist-detail-title-row">
<input
type="text"
<CountedInput
className="playlist-edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
autoFocus
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
/>
<button
type="button"
className="btn-primary"
disabled={editSaving}
disabled={editSaving ||
editDescription.length >
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
onClick={handleEditSave}
>
{editSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
@@ -710,6 +718,7 @@ export function PlaylistDetail() {
placeholder={t`Description (optional)`}
autoResize
rows={1}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/>
)
: playlist.description && (
@@ -745,7 +754,9 @@ export function PlaylistDetail() {
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? <Trans>public</Trans> : <Trans>private</Trans>}
{playlist.isPublic
? <Trans>public</Trans>
: <Trans>private</Trans>}
</span>
{playlist.ownerUsername && (
<Link
@@ -765,7 +776,9 @@ export function PlaylistDetail() {
text={t`Edited ${playlist.updatedAt.toLocaleString()}`}
>
<span className="playlist-edited-label">
<Trans>edited {relativeTime(playlist.updatedAt)}</Trans>
<Trans>
edited {relativeTime(playlist.updatedAt)}
</Trans>
</span>
</Tooltip>
)}
@@ -780,13 +793,15 @@ export function PlaylistDetail() {
</div>
{visibleDumps.length === 0
? <p className="empty-state"><Trans>No dumps in this playlist yet.</Trans></p>
? (
<p className="empty-state">
<Trans>No dumps in this playlist yet.</Trans>
</p>
)
: (
<div
className="playlist-dump-list"
onDragOver={isOwner
? (e) => e.preventDefault()
: undefined}
onDragOver={isOwner ? (e) => e.preventDefault() : undefined}
>
{visibleDumps.map((dump) => {
const isActive = activeDumpIds.has(dump.id);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
@@ -203,11 +203,15 @@ export function Search() {
)}
{state.status === "idle" && (
<p className="search-page-empty"><Trans>Enter a query to search.</Trans></p>
<p className="search-page-empty">
<Trans>Enter a query to search.</Trans>
</p>
)}
{state.status === "loading" && (
<p className="search-page-empty"><Trans>Searching</Trans></p>
<p className="search-page-empty">
<Trans>Searching</Trans>
</p>
)}
{state.status === "error" && (
@@ -236,10 +240,14 @@ export function Search() {
)}
<div ref={sentinelRef} />
{state.dumps.loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!state.dumps.hasMore && state.dumps.items.length > 0 && (
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
)}
@@ -270,7 +278,11 @@ export function Search() {
{state.status === "loaded" && tab === "playlists" && (
state.playlists.length === 0
? <p className="search-page-empty">{t`No playlists match "${q}".`}</p>
? (
<p className="search-page-empty">
{t`No playlists match "${q}".`}
</p>
)
: (
<ul className="dump-feed">
{state.playlists.map((p) => (

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { t } from "@lingui/core/macro"
import { Trans, Plural } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { Link, useParams } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
@@ -47,7 +47,9 @@ export function UserDumps() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading</Trans></p>
<p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell>
);
}
@@ -89,7 +91,11 @@ export function UserDumps() {
)}
{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) => (
@@ -108,10 +114,18 @@ export function UserDumps() {
)}
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && dumps.length > 0 && (
<p className="index-status">
<Trans>All <Plural value={dumps.length} one="# dump" other="# dumps" /> loaded.</Trans>
<Trans>
All <Plural value={dumps.length} one="# dump" other="# dumps" />
{" "}
loaded.
</Trans>
</p>
)}
</PageShell>

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import type { SubmitEvent } from "react";
import { useNavigate } from "react-router";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
@@ -59,7 +59,9 @@ export function UserLogin() {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title"><Trans>Log in</Trans></h1>
<h1 className="auth-card-title">
<Trans>Log in</Trans>
</h1>
{state.status === "error" && (
<ErrorCard title={t`Login failed`} message={state.error} />

View File

@@ -6,7 +6,7 @@ import {
useState,
} from "react";
import { Link, 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";
@@ -338,7 +338,9 @@ export function UserPlaylists() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading</Trans></p>
<p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell>
);
}
@@ -384,12 +386,17 @@ export function UserPlaylists() {
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Created ({created.items.length}{created.hasMore ? "+" : ""})
Created ({created.items.length}
{created.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
{created.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">
{created.items.map((p) => (
@@ -406,7 +413,9 @@ export function UserPlaylists() {
)}
<div ref={createdSentinelRef} />
{created.loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
</section>
@@ -414,7 +423,8 @@ export function UserPlaylists() {
<div className="profile-section-header">
<h2 className="profile-section-title">
<Trans>
Followed ({followed.items.length}{followed.hasMore ? "+" : ""})
Followed ({followed.items.length}
{followed.hasMore ? "+" : ""})
</Trans>
</h2>
</div>
@@ -433,7 +443,9 @@ export function UserPlaylists() {
)}
<div ref={followedSentinelRef} />
{followed.loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
</section>

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>
);

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
@@ -91,7 +91,9 @@ export function UserRegister() {
if (tokenState.status === "checking") {
return (
<PageShell centered>
<p className="page-loading"><Trans>Checking invite</Trans></p>
<p className="page-loading">
<Trans>Checking invite</Trans>
</p>
</PageShell>
);
}
@@ -112,7 +114,9 @@ export function UserRegister() {
return (
<PageShell centered>
<div className="auth-card">
<h1 className="auth-card-title"><Trans>Register</Trans></h1>
<h1 className="auth-card-title">
<Trans>Register</Trans>
</h1>
{formState.status === "error" && (
<ErrorCard title={t`Registration failed`} message={formState.error} />
@@ -126,6 +130,7 @@ export function UserRegister() {
required
pattern={`[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}`}
title={t`${VALIDATION.USERNAME_MIN}${VALIDATION.USERNAME_MAX} characters: letters, numbers, or underscores`}
maxLength={VALIDATION.USERNAME_MAX}
disabled={formState.status === "submitting"}
autoFocus
/>
@@ -157,7 +162,9 @@ export function UserRegister() {
</form>
<p className="auth-card-footer">
<Trans>Already have an account? <Link to="/login">Log in</Link></Trans>
<Trans>
Already have an account? <Link to="/login">Log in</Link>
</Trans>
</p>
</div>
</PageShell>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { API_URL } from "../config/api.ts";
@@ -69,6 +69,7 @@ export function UserUpvoted() {
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// setVotedIds + prevMyVotesRef must be co-located to stay consistent.
// eslint-disable-next-line react-hooks/set-state-in-effect
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes);
@@ -87,6 +88,8 @@ export function UserUpvoted() {
if (voterId !== profileUserId) return;
if (action === "remove") {
// 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);
@@ -116,7 +119,9 @@ export function UserUpvoted() {
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading"><Trans>Loading</Trans></p>
<p className="page-loading">
<Trans>Loading</Trans>
</p>
</PageShell>
);
}
@@ -148,7 +153,11 @@ export function UserUpvoted() {
/>
{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) => {
@@ -177,11 +186,21 @@ export function UserUpvoted() {
<div ref={sentinelRef} />
{loadingMore && (
<p className="feed-loading-more"><Trans>Loading more</Trans></p>
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && visibleDumps.length > 0 && (
<p className="index-status">
<Trans>All <Plural value={votes.length} one="# upvoted dump" other="# upvoted dumps" /> loaded.</Trans>
<Trans>
All{" "}
<Plural
value={votes.length}
one="# upvoted dump"
other="# upvoted dumps"
/>{" "}
loaded.
</Trans>
</p>
)}
</PageShell>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -71,7 +71,11 @@ function FollowedSubFeed({
const sentinelRef = useInfiniteScroll(onLoadMore, enabled);
if (state.status === "loading") {
return <p className="index-status"><Trans>Loading</Trans></p>;
return (
<p className="index-status">
<Trans>Loading</Trans>
</p>
);
}
if (state.status === "error") {
return <ErrorCard title={t`Failed to load`} message={state.error} />;
@@ -100,7 +104,11 @@ function FollowedSubFeed({
))}
</ul>
<div ref={sentinelRef} />
{state.loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{state.loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
</>
);
}
@@ -219,8 +227,7 @@ export function FollowedFeed({
})
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
}, [token, usersState.status, playlistsState.status]);
// Scroll save
useScrollSave(

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -26,10 +26,20 @@ export function HotFeed(
[dumps],
);
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (loading) {
return (
<p className="index-status">
<Trans>Loading</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) {
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
}
return (
@@ -49,9 +59,15 @@ export function HotFeed(
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && sorted.length > 0 && (
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ErrorCard } from "../../components/ErrorCard.tsx";
import {
@@ -38,10 +38,20 @@ export function JournalFeed(
});
}, [dumps]);
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (loading) {
return (
<p className="index-status">
<Trans>Loading</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (tiered.length === 0) {
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
}
return (
@@ -62,9 +72,15 @@ export function JournalFeed(
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && tiered.length > 0 && (
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
import { ErrorCard } from "../../components/ErrorCard.tsx";
@@ -26,10 +26,20 @@ export function NewFeed(
[dumps],
);
if (loading) return <p className="index-status"><Trans>Loading</Trans></p>;
if (loading) {
return (
<p className="index-status">
<Trans>Loading</Trans>
</p>
);
}
if (error) return <ErrorCard title={t`Failed to load`} message={error} />;
if (sorted.length === 0) {
return <p className="index-status"><Trans>No dumps yet. Be the first!</Trans></p>;
return (
<p className="index-status">
<Trans>No dumps yet. Be the first!</Trans>
</p>
);
}
return (
@@ -49,9 +59,15 @@ export function NewFeed(
))}
</ul>
<div ref={sentinelRef} />
{loadingMore && <p className="feed-loading-more"><Trans>Loading more</Trans></p>}
{loadingMore && (
<p className="feed-loading-more">
<Trans>Loading more</Trans>
</p>
)}
{!hasMore && sorted.length > 0 && (
<p className="feed-end"><Trans>You've reached the end.</Trans></p>
<p className="feed-end">
<Trans>You've reached the end.</Trans>
</p>
)}
</>
);