v3: localization fixes, char counters & limits on all text fields, ux fixes
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user