v3: lots of small UI tweaks and fixes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
17
src/pages/NotFound.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user