v3: lots of small UI tweaks and fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s

This commit is contained in:
khannurien
2026-04-11 08:35:16 +00:00
parent 362967472c
commit b822f861ed
22 changed files with 673 additions and 500 deletions

View File

@@ -252,42 +252,42 @@ export function Dump() {
)}
</div>
<div className="dump-header-right">
<h1 className="dump-title">{dump.title}</h1>
<div className="dump-op">
<Avatar
userId={dump.userId}
username={op?.username ?? "?"}
hasAvatar={!!op?.avatarMime}
size={22}
/>
{op
? (
<Link to={`/users/${op.username}`} className="dump-op-link">
{op.username}
</Link>
)
: <span className="dump-op-link"></span>}
<Tooltip text={dump.createdAt.toLocaleString()}>
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
>
{relativeTime(dump.createdAt)}
</time>
</Tooltip>
{dump.updatedAt && (
<Tooltip text={t`Edited ${dump.updatedAt.toLocaleString()}`}>
<span className="dump-edited-label">
<Trans>edited {relativeTime(dump.updatedAt)}</Trans>
</span>
<h1 className="dump-title">{dump.title}</h1>
<div className="dump-op">
<Avatar
userId={dump.userId}
username={op?.username ?? "?"}
hasAvatar={!!op?.avatarMime}
size={22}
/>
{op
? (
<Link to={`/users/${op.username}`} className="dump-op-link">
{op.username}
</Link>
)
: <span className="dump-op-link"></span>}
<Tooltip text={dump.createdAt.toLocaleString()}>
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
>
{relativeTime(dump.createdAt)}
</time>
</Tooltip>
)}
{dump.isPrivate && (
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
{dump.updatedAt && (
<Tooltip text={t`Edited ${dump.updatedAt.toLocaleString()}`}>
<span className="dump-edited-label">
<Trans>edited {relativeTime(dump.updatedAt)}</Trans>
</span>
</Tooltip>
)}
{dump.isPrivate && (
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
</div>
</div>

View File

@@ -9,7 +9,6 @@ import {
import { useLocation } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx";
import { FeedTabBar } from "../components/FeedTabBar.tsx";
import { type FeedTab, VALID_TABS } from "../config/feedTabs.ts";
@@ -249,7 +248,6 @@ export function Index() {
<div className="header-center-slot">
<PresenceRow />
<FeedTabBar />
<SearchBar collapsible />
</div>
}
disableNew={dumpsState.status === "error"}

17
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { Link } from "react-router";
import { Trans } from "@lingui/react/macro";
import { PageShell } from "../components/PageShell.tsx";
export function NotFound() {
return (
<PageShell centered>
<h1>404</h1>
<p>
<Trans>This page does not exist.</Trans>
</p>
<Link to="/" className="btn-primary">
<Trans>Go home</Trans>
</Link>
</PageShell>
);
}

View File

@@ -3,7 +3,7 @@ import { Link, useSearchParams } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx";
import { TabBar } from "../components/TabBar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
@@ -178,28 +178,26 @@ export function Search() {
return (
<div className="page-shell">
<AppHeader centerSlot={<SearchBar />} />
<AppHeader />
<main className="search-page">
{q && (
<div className="search-tabs">
{(["dumps", "users", "playlists"] as Tab[]).map((tabKey) => (
<button
key={tabKey}
type="button"
className={`feed-sort-btn${tab === tabKey ? " active" : ""}`}
onClick={() => setTab(tabKey)}
>
{tabLabel(
tabKey,
tabKey === "dumps"
? dumpCount
: tabKey === "users"
? userCount
: playlistCount,
)}
</button>
))}
</div>
<TabBar
tabs={(["dumps", "users", "playlists"] as Tab[]).map((key) => ({
key,
label: tabLabel(
key,
key === "dumps"
? dumpCount
: key === "users"
? userCount
: playlistCount,
),
}))}
activeTab={tab}
onChange={setTab}
className="search-tabs"
innerClassName="search-tabs-inner"
/>
)}
{state.status === "idle" && (

View File

@@ -111,7 +111,9 @@ export function UserDumps() {
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+<span className="btn-new-label"><Trans> New dump</Trans></span>
+<span className="btn-new-label">
<Trans>New dump</Trans>
</span>
</button>
)}
/>

View File

@@ -53,6 +53,7 @@ import { friendlyFetchError } from "../utils/apiError.ts";
import { TextEditor } from "../components/TextEditor.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { ChangePasswordModal } from "../components/ChangePasswordModal.tsx";
import { TabBar } from "../components/TabBar.tsx";
function InviteButton() {
const { authFetch } = useAuth();
@@ -145,6 +146,7 @@ type InviteTreeState =
| { status: "loaded"; entries: InviteTreeEntry[] };
type InviteTreeNode = InviteTreeEntry & { children: InviteTreeNode[] };
type ProfileTab = "dumps" | "playlists" | "followed" | "invitees" | "settings";
function buildInviteTree(
entries: InviteTreeEntry[],
@@ -282,9 +284,7 @@ export function UserPublicProfile() {
const [emailError, setEmailError] = useState<string | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null);
const [tab, setTab] = useState<
"dumps" | "playlists" | "followed" | "invitees" | "settings"
>("dumps");
const [tab, setTab] = useState<ProfileTab>("dumps");
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
const [followedState, setFollowedState] = useState<FollowedState>(null);
const [inviteTreeState, setInviteTreeState] = useState<InviteTreeState>(null);
@@ -782,107 +782,112 @@ export function UserPublicProfile() {
</label>
)}
</div>
<div>
<h1 className="profile-username">{profileUser.username}</h1>
{profileUser.invitedByUsername
? (
<p className="profile-invited-by">
<Trans>invited by</Trans>{" "}
<Link
to={`/users/${profileUser.invitedByUsername}`}
className="profile-invited-by-link"
>
@{profileUser.invitedByUsername}
</Link>
</p>
)
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{isOwnProfile && (
emailEditing
<div className="profile-info">
<div className="profile-info-scroll">
<h1 className="profile-username">{profileUser.username}</h1>
{profileUser.invitedByUsername
? (
<form
className="profile-email-editor"
onSubmit={(e) => {
e.preventDefault();
handleEmailSave();
}}
>
<input
type="email"
className="profile-email-input"
value={emailDraft}
onChange={(e) => setEmailDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setEmailEditing(false);
}}
disabled={emailSaving}
autoFocus
/>
<div className="profile-email-actions">
<button
type="submit"
className="profile-email-btn profile-email-btn--save"
disabled={emailSaving || !emailDraft.trim()}
>
{emailSaving
? <Trans>Saving</Trans>
: <Trans>Save</Trans>}
</button>
<button
type="button"
className="profile-email-btn profile-email-btn--cancel"
onClick={() => setEmailEditing(false)}
disabled={emailSaving}
>
<Trans>Cancel</Trans>
</button>
</div>
{emailError && (
<ErrorCard title={t`Failed to save`} message={emailError} />
)}
</form>
)
: (
<p
className="profile-email-display"
onClick={() => {
setEmailDraft(me?.email ?? "");
setEmailError(null);
setEmailEditing(true);
}}
title="Edit email"
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
</span>
<p className="profile-invited-by">
<Trans>invited by</Trans>{" "}
<Link
to={`/users/${profileUser.invitedByUsername}`}
className="profile-invited-by-link"
>
@{profileUser.invitedByUsername}
</Link>
</p>
)
)}
{avatarError && (
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={profileUser.id}
targetUsername={profileUser.username}
/>
)}
{isOwnProfile && (
<div className="profile-own-actions">
<InviteButton />
<button type="button" className="btn-border" onClick={logout}>
<Trans>Log out</Trans>
</button>
</div>
)}
: (
<p className="profile-invited-by profile-invited-by--founding">
O.G.
</p>
)}
{isOwnProfile && (
emailEditing
? (
<form
className="profile-email-editor"
onSubmit={(e) => {
e.preventDefault();
handleEmailSave();
}}
>
<input
type="email"
className="profile-email-input"
value={emailDraft}
onChange={(e) => setEmailDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setEmailEditing(false);
}}
disabled={emailSaving}
autoFocus
/>
<div className="profile-email-actions">
<button
type="submit"
className="profile-email-btn profile-email-btn--save"
disabled={emailSaving || !emailDraft.trim()}
>
{emailSaving
? <Trans>Saving</Trans>
: <Trans>Save</Trans>}
</button>
<button
type="button"
className="profile-email-btn profile-email-btn--cancel"
onClick={() => setEmailEditing(false)}
disabled={emailSaving}
>
<Trans>Cancel</Trans>
</button>
</div>
{emailError && (
<ErrorCard
title={t`Failed to save`}
message={emailError}
/>
)}
</form>
)
: (
<p
className="profile-email-display"
onClick={() => {
setEmailDraft(me?.email ?? "");
setEmailError(null);
setEmailEditing(true);
}}
title="Edit email"
>
{me?.email ?? t`Add email…`}
<span className="profile-description-edit-btn" aria-hidden>
</span>
</p>
)
)}
{avatarError && (
<ErrorCard
title={t`Failed to update avatar`}
message={avatarError}
/>
)}
{!isOwnProfile && (
<FollowUserButton
targetUserId={profileUser.id}
targetUsername={profileUser.username}
/>
)}
{isOwnProfile && (
<div className="profile-own-actions">
<InviteButton />
<button type="button" className="btn-border" onClick={logout}>
<Trans>Log out</Trans>
</button>
</div>
)}
</div>
</div>
</div>
@@ -958,47 +963,21 @@ export function UserPublicProfile() {
</div>
)}
<div className="feed-sort-scroller profile-tabs-scroller">
<div className="profile-tabs feed-sort">
<button
type="button"
className={`feed-sort-btn${tab === "dumps" ? " active" : ""}`}
onClick={() => setTab("dumps")}
>
<Trans>Dumps</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "playlists" ? " active" : ""}`}
onClick={() => setTab("playlists")}
>
<Trans>Playlists</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
onClick={() => setTab("followed")}
>
<Trans>Followed</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${tab === "invitees" ? " active" : ""}`}
onClick={() => setTab("invitees")}
>
<Trans>Invitees</Trans>
</button>
{isOwnProfile && (
<button
type="button"
className={`feed-sort-btn${tab === "settings" ? " active" : ""}`}
onClick={() => setTab("settings")}
>
<Trans>Settings</Trans>
</button>
)}
</div>
</div>
<TabBar
tabs={[
{ key: "dumps", label: <Trans>Dumps</Trans> },
{ key: "playlists", label: <Trans>Playlists</Trans> },
{ key: "followed", label: <Trans>Followed</Trans> },
{ key: "invitees", label: <Trans>Invitees</Trans> },
...(isOwnProfile
? [{ key: "settings" as const, label: <Trans>Settings</Trans> }]
: []),
]}
activeTab={tab}
onChange={(key) => setTab(key)}
className="profile-tabs-scroller"
innerClassName="profile-tabs"
/>
{tab === "dumps" && (
<div className="profile-columns">
@@ -1329,7 +1308,9 @@ function DumpList(
className="new-playlist-toggle"
onClick={() => setCreateModalOpen(true)}
>
+<span className="btn-new-label"><Trans> New dump</Trans></span>
+<span className="btn-new-label">
<Trans>New dump</Trans>
</span>
</button>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { TabBar } from "../../components/TabBar.tsx";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DumpCard } from "../../components/DumpCard.tsx";
@@ -328,22 +329,15 @@ export function FollowedFeed({
return (
<div className="followed-feed">
<div className="feed-sort followed-sub-nav">
<button
type="button"
className={`feed-sort-btn${section === "users" ? " active" : ""}`}
onClick={() => setSection("users")}
>
<Trans>From people</Trans>
</button>
<button
type="button"
className={`feed-sort-btn${section === "playlists" ? " active" : ""}`}
onClick={() => setSection("playlists")}
>
<Trans>From playlists</Trans>
</button>
</div>
<TabBar
tabs={[
{ key: "users", label: <Trans>From people</Trans> },
{ key: "playlists", label: <Trans>From playlists</Trans> },
]}
activeTab={section}
onChange={setSection}
innerClassName="followed-sub-nav"
/>
{section === "users" && (
<FollowedSubFeed